diff --git a/AssetEditor/AssetEditor.csproj b/AssetEditor/AssetEditor.csproj index 7675ca270..732d07c22 100644 --- a/AssetEditor/AssetEditor.csproj +++ b/AssetEditor/AssetEditor.csproj @@ -1,4 +1,4 @@ - + Exe preview diff --git a/AssetEditor/Services/EditorManager.cs b/AssetEditor/Services/EditorManager.cs index 5da794368..1e4e1970d 100644 --- a/AssetEditor/Services/EditorManager.cs +++ b/AssetEditor/Services/EditorManager.cs @@ -72,7 +72,7 @@ public IEditorInterface CreateFromFile(PackFile file, EditorEnums? preferedEdito { if (existingFileEditor.CurrentFile == file) { - _logger.Here().Information($"Attempting to open file '{file.Name}', but is is already open"); + _logger.Here().Information($"Attempting to open file '{fullFileName}', but is is already open"); SelectedEditorIndex = i; return CurrentEditorsList[i]; } @@ -80,7 +80,7 @@ public IEditorInterface CreateFromFile(PackFile file, EditorEnums? preferedEdito } // Open the file - _logger.Here().Information($"Opening {file.Name} with {editorViewModel?.GetType().Name}"); + _logger.Here().Information($"Opening {fullFileName} with {editorViewModel?.GetType().Name}"); fileEditor.LoadFile(file); } diff --git a/AssetEditor/ViewModels/MainViewModel.cs b/AssetEditor/ViewModels/MainViewModel.cs index f9ee1739c..2e55bcb1d 100644 --- a/AssetEditor/ViewModels/MainViewModel.cs +++ b/AssetEditor/ViewModels/MainViewModel.cs @@ -63,7 +63,7 @@ public MainViewModel( [RelayCommand] private void Closing(IEditorInterface editor) { - var hasUnsavedPackFiles = FileTree.Files.Any(node => node.UnsavedChanged); + var hasUnsavedPackFiles = FileTree.Files.Any(node => node.UnsavedChanges.HasChanges); if (EditorManager.ShouldBlockCloseCommand(editor, hasUnsavedPackFiles)) { IsClosingWithoutPrompt = true; diff --git a/Editors/Audio/ContextMenu/ExportCAVp8AsIvfCommand.cs b/Editors/Audio/ContextMenu/ExportCAVp8AsIvfCommand.cs index a6ce81bbe..e33c0aee5 100644 --- a/Editors/Audio/ContextMenu/ExportCAVp8AsIvfCommand.cs +++ b/Editors/Audio/ContextMenu/ExportCAVp8AsIvfCommand.cs @@ -2,9 +2,11 @@ using System.IO; using Editors.Audio.Shared.Utilities; using Shared.Core.Misc; +using Shared.Core.PackFiles.Models; using Shared.Core.Services; using Shared.Ui.BaseDialogs.PackFileTree; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Editors.Audio.ContextMenu { @@ -14,12 +16,16 @@ public class ExportCAVp8AsIvfCommand(IStandardDialogs standardDialogs, IFileSyst private readonly IFileSystemAccess _fileSystemAccess = fileSystemAccess; public string GetDisplayName(TreeNode node) => "Export as IVF"; - public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null; - public bool IsEnabled(TreeNode node) => node.Item != null && node.Item.Name.EndsWith(".ca_vp8", StringComparison.OrdinalIgnoreCase); + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && TreeNodeHelper.GetPackFile(node) != null; + public bool IsEnabled(TreeNode node) + { + var packFile = TreeNodeHelper.GetPackFile(node); + return packFile != null && packFile.Name.EndsWith(".ca_vp8", StringComparison.OrdinalIgnoreCase); + } public void Execute(TreeNode selectedNode) { - var packFile = selectedNode.Item; + var packFile = TreeNodeHelper.GetPackFile(selectedNode); if (packFile == null) return; diff --git a/Editors/Audio/ContextMenu/ExportCAVp8AsWebMCommand.cs b/Editors/Audio/ContextMenu/ExportCAVp8AsWebMCommand.cs index 644e25709..f3f9f492c 100644 --- a/Editors/Audio/ContextMenu/ExportCAVp8AsWebMCommand.cs +++ b/Editors/Audio/ContextMenu/ExportCAVp8AsWebMCommand.cs @@ -5,9 +5,11 @@ using Editors.Audio.Shared.Utilities; using Shared.Core.Misc; 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; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Editors.Audio.ContextMenu { @@ -25,13 +27,17 @@ public class ExportCAVp8AsWebMCommand( private readonly IMovieAudioResolver _movieAudioResolver = movieAudioResolver; public string GetDisplayName(TreeNode node) => "Export as WebM"; - public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null; - public bool IsEnabled(TreeNode node) => node.Item != null && node.Item.Name.EndsWith(".ca_vp8", StringComparison.OrdinalIgnoreCase); + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && TreeNodeHelper.GetPackFile(node) != null; + public bool IsEnabled(TreeNode node) + { + var packFile = TreeNodeHelper.GetPackFile(node); + return packFile != null && packFile.Name.EndsWith(".ca_vp8", StringComparison.OrdinalIgnoreCase); + } public void Execute(TreeNode selectedNode) { - var caVp8PackFile = selectedNode.Item; - if (caVp8PackFile == null) + var packFile = TreeNodeHelper.GetPackFile(selectedNode); + if (packFile == null) return; var dialogResult = _standardDialogs.ShowSystemFolderBrowserDialog(); @@ -42,10 +48,10 @@ public void Execute(TreeNode selectedNode) _audioRepository.Load(Wh3LanguageInformation.GetAllLanguages()); - var caVp8PackFilePath = _packFileService.GetFullPath(caVp8PackFile); + var caVp8PackFilePath = _packFileService.GetFullPath(packFile); var wemPackFile = _movieAudioResolver.ResolveMovieWem(caVp8PackFilePath); - var webMPath = Path.Combine(dialogResult.FolderPath, Path.ChangeExtension(caVp8PackFile.Name, ".webm")); - var webMBytes = CAVp8Exporter.ExportToWebM(caVp8PackFile, wemPackFile); + var webMPath = Path.Combine(dialogResult.FolderPath, Path.ChangeExtension(packFile.Name, ".webm")); + var webMBytes = CAVp8Exporter.ExportToWebM(packFile, wemPackFile); _fileSystemAccess.FileWriteAllBytes(webMPath, webMBytes); } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/ContextMenu/IImportFileContextMenuHelper.cs b/Editors/ImportExportEditor/Editors.ImportExport/ContextMenu/IImportFileContextMenuHelper.cs index 45b840eca..f3ad64430 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/ContextMenu/IImportFileContextMenuHelper.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/ContextMenu/IImportFileContextMenuHelper.cs @@ -6,6 +6,6 @@ namespace Editors.ImportExport.ContextMenu public interface IImportFileContextMenuHelper { bool CanImportFile(PackFile file); - void ShowDialog(TreeNode node); + void ShowDialog(IPackFileContainer container, TreeNode node); } } \ No newline at end of file diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/AdvancedExportCommand.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/AdvancedExportCommand.cs index c9ba6e577..6ca7df9a5 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/AdvancedExportCommand.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/AdvancedExportCommand.cs @@ -1,15 +1,28 @@ -using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; using Shared.Ui.BaseDialogs.PackFileTree; using Editors.ImportExport.ContextMenu; +using Shared.Core.PackFiles.Models; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; 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 bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && TreeNodeHelper.GetPackFile(node) != null; + public bool IsEnabled(TreeNode node) + { + var packFile = TreeNodeHelper.GetPackFile(node); + return packFile != null && exportFileContextMenuHelper.CanExportFile(packFile); + } - public void Execute(TreeNode selectedNode) => exportFileContextMenuHelper.ShowDialog(selectedNode.Item); + public void Execute(TreeNode selectedNode) + { + var packFile = TreeNodeHelper.GetPackFile(selectedNode); + if (packFile == null) + return; + + exportFileContextMenuHelper.ShowDialog(packFile); + } } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Importing/AdvancedImportCommand.cs b/Editors/ImportExportEditor/Editors.ImportExport/Importing/AdvancedImportCommand.cs index 79c5c8e48..90a50340a 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Importing/AdvancedImportCommand.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Importing/AdvancedImportCommand.cs @@ -1,15 +1,30 @@ -using Editors.ImportExport.ContextMenu; +using Editors.ImportExport.ContextMenu; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; using Shared.Ui.BaseDialogs.PackFileTree; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Editors.ImportExport.Importing { - public class AdvancedImportCommand(IImportFileContextMenuHelper importFileContextMenuHelper) : IContextMenuCommand + public class AdvancedImportCommand(IPackFileService packFileService, IImportFileContextMenuHelper importFileContextMenuHelper) : IContextMenuCommand { public string GetDisplayName(TreeNode node) => "Advanced Import"; - public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.Directory && !node.FileOwner.IsCaPackFile; + public bool ShouldAdd(TreeNode node) + { + var container = TreeNodeHelper.GetPackFileContainer(node); + return node.NodeType == NodeType.Directory && container is { IsCaPackFile: false }; + } + public bool IsEnabled(TreeNode node) => true; - public void Execute(TreeNode selectedNode) => importFileContextMenuHelper.ShowDialog(selectedNode); + public void Execute(TreeNode selectedNode) + { + var container = TreeNodeHelper.GetPackFileContainer(selectedNode); + if (container == null) + return; + + importFileContextMenuHelper.ShowDialog(container, selectedNode); + } } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Importing/ImportFIleContextMenuHelper.cs b/Editors/ImportExportEditor/Editors.ImportExport/Importing/ImportFIleContextMenuHelper.cs index 600fcab1e..03a42f9ec 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Importing/ImportFIleContextMenuHelper.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Importing/ImportFIleContextMenuHelper.cs @@ -33,7 +33,7 @@ public bool CanImportFile(PackFile filePath) return false; } - public void ShowDialog(TreeNode clickedNode) => - _uiCommandFactory.Create().Execute(clickedNode.FileOwner, clickedNode.GetFullPath()); + public void ShowDialog(IPackFileContainer container, TreeNode clickedNode) => + _uiCommandFactory.Create().Execute(container, clickedNode.GetFullPath()); } } diff --git a/Editors/Kitbashing/KitbasherEditor/Core/KitbashViewDropHandler.cs b/Editors/Kitbashing/KitbasherEditor/Core/KitbashViewDropHandler.cs index 3fbc92cc3..64de2f9be 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/KitbashViewDropHandler.cs +++ b/Editors/Kitbashing/KitbasherEditor/Core/KitbashViewDropHandler.cs @@ -1,6 +1,7 @@ using System.IO; using Editors.KitbasherEditor.UiCommands; using Shared.Core.Events; +using Shared.Core.PackFiles.Models; using Shared.Ui.BaseDialogs.PackFileTree; namespace Editors.KitbasherEditor.ViewModels @@ -14,20 +15,23 @@ public KitbashViewDropHandler(IUiCommandFactory uiCommandFactory) _uiCommandFactory = uiCommandFactory; } - public bool AllowDrop(TreeNode node, TreeNode targeNode = null) + public bool AllowDrop(PackFile file, PackFile targeNode = null) { - if (node != null && node.NodeType == NodeType.File) + if (file != null) { - var extension = Path.GetExtension(node.Name).ToLower(); + var extension = Path.GetExtension(file.Name).ToLower(); if (extension == ".rigid_model_v2" || extension == ".wsmodel" || extension == ".variantmeshdefinition") return true; } return false; } - public bool Drop(TreeNode node) + public bool Drop(PackFile file) { - _uiCommandFactory.Create().Execute(node.Item); + if (file == null) + return false; + + _uiCommandFactory.Create().Execute(file); return true; } } diff --git a/Editors/Kitbashing/KitbasherEditor/Core/KitbasherView.xaml.cs b/Editors/Kitbashing/KitbasherEditor/Core/KitbasherView.xaml.cs index 0db9cedd5..6019a1835 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/KitbasherView.xaml.cs +++ b/Editors/Kitbashing/KitbasherEditor/Core/KitbasherView.xaml.cs @@ -1,5 +1,6 @@ using System.Windows; using System.Windows.Controls; +using Shared.Core.PackFiles.Models; using Shared.Ui.BaseDialogs.PackFileTree; using Shared.Ui.Common; using Shared.Ui.Common.MenuSystem; @@ -18,12 +19,12 @@ public KitbasherView() private void treeView_Drop(object sender, DragEventArgs e) { - var dropTarget = DataContext as IDropTarget; + var dropTarget = DataContext as IDropTarget; if (dropTarget != null) { var formats = e.Data.GetFormats(); object droppedObject = e.Data.GetData(formats[0]); - var node = droppedObject as TreeNode; + var node = droppedObject as PackFile; if (dropTarget.AllowDrop(node)) { diff --git a/Editors/Kitbashing/KitbasherEditor/Core/KitbasherViewModel.cs b/Editors/Kitbashing/KitbasherEditor/Core/KitbasherViewModel.cs index 78db637b8..0890a058e 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/KitbasherViewModel.cs +++ b/Editors/Kitbashing/KitbasherEditor/Core/KitbasherViewModel.cs @@ -15,7 +15,6 @@ using Shared.Core.PackFiles.Models; using Shared.Core.Services; using Shared.Core.ToolCreation; -using Shared.Ui.BaseDialogs.PackFileTree; using Shared.Ui.Common; namespace Editors.KitbasherEditor.ViewModels @@ -24,7 +23,7 @@ public partial class KitbasherViewModel : ObservableObject, IEditorInterface, IFileEditor, ISaveableEditor, - IDropTarget + IDropTarget { private readonly ILogger _logger = Logging.Create(); @@ -110,8 +109,8 @@ public bool HasUnsavedChanges } } - public bool AllowDrop(TreeNode node, TreeNode targeNode = null) => _dropHandler.AllowDrop(node, targeNode); - public bool Drop(TreeNode node, TreeNode targeNode = null) => _dropHandler.Drop(node); + public bool AllowDrop(PackFile node, PackFile targeNode = null) => _dropHandler.AllowDrop(node, targeNode); + public bool Drop(PackFile node, PackFile targeNode = null) => _dropHandler.Drop(node); void OnFileSaved(ScopedFileSavedEvent notification) { diff --git a/Editors/Reports/Geometry/RmvToTextReport.cs b/Editors/Reports/Geometry/RmvToTextReport.cs index 969ec7ca0..98aee2c59 100644 --- a/Editors/Reports/Geometry/RmvToTextReport.cs +++ b/Editors/Reports/Geometry/RmvToTextReport.cs @@ -8,6 +8,7 @@ using Shared.GameFormats.RigidModel.MaterialHeaders; using Shared.Ui.BaseDialogs.PackFileTree; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Editors.Reports.Geometry { @@ -17,11 +18,19 @@ public class RmvToTextCommand(RmvToTextReport report) : IContextMenuCommand 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 bool IsEnabled(TreeNode node) + { + var packFile = TreeNodeHelper.GetPackFile(node); + return node.NodeType == NodeType.File && packFile != null && node.Name.EndsWith(".rigid_model_v2", StringComparison.OrdinalIgnoreCase); + } public void Execute(TreeNode node) { - report.Generate(node.Item!); + var packFile = TreeNodeHelper.GetPackFile(node); + if (packFile == null) + return; + + report.Generate(packFile); } } diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/CachedPackFileContainer.cs b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/CachedPackFileContainer.cs index 84b9a64ba..0c81f2558 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/CachedPackFileContainer.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/CachedPackFileContainer.cs @@ -1,4 +1,7 @@ -using Microsoft.EntityFrameworkCore; +using System.Diagnostics; +using System.Windows.Forms; +using Microsoft.EntityFrameworkCore; +using Shared.Core.ErrorHandling; using Shared.Core.PackFiles.Models.FileSources; using Shared.Core.PackFiles.Serialization.CacheDatabase; using Shared.Core.PackFiles.Utility; @@ -8,6 +11,8 @@ namespace Shared.Core.PackFiles.Models.Containers { internal class CachedPackFileContainer : IPackFileContainerInternal { + private readonly ILogger _logger = Logging.Create(); + private readonly CacheDbContext _db; private readonly object _dbLock = new(); @@ -129,11 +134,24 @@ public bool ContainsFile(string path) public Dictionary GetAllFiles() { + var time = Stopwatch.StartNew(); List entries; lock (_dbLock) { + //var x = _db.Files + // .GroupBy(x=>x.FolderPath) + // .Select(g => new + // { + // Folder = g.Key, + // FileList = g.Select(x => x.FileName).ToList() + // }) + // .ToList(); + // + // + entries = _db.Files.ToList(); } + _logger.Here().Information("Getting all files from cached container took {ElapsedMilliseconds} ms", time.ElapsedMilliseconds); var parentCache = new Dictionary(StringComparer.OrdinalIgnoreCase); var result = new Dictionary(entries.Count); @@ -154,6 +172,8 @@ public Dictionary GetAllFiles() result[entry.RelativePath] = new PackFile(entry.FileName, source); } + _logger.Here().Information("Getting all files and processing from cached container took {ElapsedMilliseconds} ms", time.ElapsedMilliseconds); + return result; } diff --git a/Shared/SharedUI/Shared.Ui/Assembly.cs b/Shared/SharedUI/Shared.Ui/Assembly.cs new file mode 100644 index 000000000..58369af68 --- /dev/null +++ b/Shared/SharedUI/Shared.Ui/Assembly.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Shared.UiTest")] diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Commands/DoubleClickCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Commands/DoubleClickCommand.cs new file mode 100644 index 000000000..9e2157034 --- /dev/null +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Commands/DoubleClickCommand.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using System.Windows.Input; +using Shared.Core.Events; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; +using Shared.Ui.Common.MenuSystem; + +namespace Shared.Ui.BaseDialogs.PackFileTree.Commands +{ + public class DoubleClickCommand(IPackFileService packFileService, IWindowsKeyboard windowKeyboard) : IUiCommand + { + public int MaxExpandCount { get; set; } = 200; + + public void Execute(TreeNode? node, TreeNode? selectedItem, Action setSelectedItem, Action openFile) + { + var targetNode = node ?? selectedItem; + if (targetNode == null) + return; + + if (!ReferenceEquals(selectedItem, targetNode)) + setSelectedItem(targetNode); + + if (targetNode.NodeType == NodeType.File) + { + var selectedFile = FindPackFile(targetNode); + if (selectedFile != null) + openFile(selectedFile); + } + else if (targetNode.NodeType == NodeType.Directory || targetNode.NodeType == NodeType.Root) + { + targetNode.IsNodeExpanded = !targetNode.IsNodeExpanded; + + if (windowKeyboard.IsKeyDown(Key.LeftCtrl)) + { + var numChildren = targetNode.EnumerateFileNodesDepthFirst().Where(n => n.IsVisible).Take(MaxExpandCount + 1).Count(); + if (numChildren < MaxExpandCount) + targetNode.ExpandIfVisible(true); + } + } + } + + private PackFile? FindPackFile(TreeNode node) + { + if (node.NodeType != NodeType.File) + return null; + + var root = TreeNodeHelper.GetRootNode(node); + var container = (root as RootTreeNode)?.Owner; + if (container == null) + return null; + + return packFileService.FindFile(node.GetFullPath(), container); + } + } +} 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 fa602e911..f6fe07e53 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommand.cs @@ -1,7 +1,9 @@ using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; using Shared.Core.Services; using Serilog; using Shared.Core.ErrorHandling; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -15,11 +17,19 @@ public class ClosePackContainerFileCommand(IPackFileService packFileService, ISt public void Execute(TreeNode selectedNode) { - var packDescription = CommandLoggingHelper.DescribePack(selectedNode.FileOwner); + var container = TreeNodeHelper.GetPackFileContainer(selectedNode); + if (container == null) + { + _logger.Here().Warning($"Close blocked because no container was resolved for '{CommandLoggingHelper.DescribeNode(selectedNode)}'"); + standardDialogs.ShowDialogBox("Unable to resolve selected packfile"); + return; + } + + var packDescription = CommandLoggingHelper.DescribePack(container); if (standardDialogs.ShowYesNoBox("Are you sure you want to close the packfile?", "") == ShowMessageBoxResult.OK) { _logger.Here().Information($"Closing pack file container '{packDescription}' from context menu"); - packFileService.UnloadPackContainer(selectedNode.FileOwner); + packFileService.UnloadPackContainer(container); } else { 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 ab3f966e1..84cbfdd4d 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommand.cs @@ -1,5 +1,6 @@ using Serilog; using Shared.Core.ErrorHandling; +using Shared.Core.PackFiles.Models; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -20,7 +21,7 @@ public void Execute(TreeNode _selectedNode) void CollapsAllRecursive(TreeNode node) { node.IsNodeExpanded = false; - foreach (var child in node.BackingChildren) + foreach (var child in node.Children) CollapsAllRecursive(child); } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CommandLoggingHelper.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CommandLoggingHelper.cs index 927937d7a..5e2f5ece3 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CommandLoggingHelper.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CommandLoggingHelper.cs @@ -1,3 +1,4 @@ +using System; using Shared.Core.PackFiles.Models; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands @@ -9,9 +10,6 @@ public static string DescribeNode(TreeNode? node) if (node == null) return ""; - if (node.NodeType == NodeType.Root) - return DescribePack(node.FileOwner); - var path = node.GetFullPath(); return string.IsNullOrWhiteSpace(path) ? node.Name : path; } 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 7eaffb8fe..00255f03c 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommand.cs @@ -1,21 +1,21 @@ using System.Windows; -using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; using Serilog; using Shared.Core.ErrorHandling; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class CopyNodePathCommand(IPackFileService packFileService) : IContextMenuCommand + public class CopyNodePathCommand() : IContextMenuCommand { private readonly ILogger _logger = Logging.Create(); public string GetDisplayName(TreeNode node) => "Copy full path"; - public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File; public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) { - var path = packFileService.GetFullPath(_selectedNode.Item!); + var path = _selectedNode.GetFullPath(); Clipboard.SetText(path); _logger.Here().Information($"Copied full path '{path}' from node '{CommandLoggingHelper.DescribeNode(_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 ef6c61952..d793d2851 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyToEditablePackCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyToEditablePackCommand.cs @@ -1,10 +1,8 @@ -using System; -using System.Windows; +using Serilog; +using Shared.Core.ErrorHandling; using Shared.Core.PackFiles; using Shared.Core.Services; -using Shared.Ui.Common; -using Serilog; -using Shared.Core.ErrorHandling; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -15,29 +13,38 @@ public class CopyToEditablePackCommand(IPackFileService packFileService, IStanda public string GetDisplayName(TreeNode node) => "Copy to editable pack"; public bool ShouldAdd(TreeNode node) { + var container = TreeNodeHelper.GetPackFileContainer(node); var editablePack = packFileService.GetEditablePack(); - return editablePack != null && editablePack != node.FileOwner; + return editablePack != null && container != null && editablePack != container; } public bool IsEnabled(TreeNode node) => true; - public void Execute(TreeNode _selectedNode) + public void Execute(TreeNode selectedNode) { var editablePack = packFileService.GetEditablePack(); if (editablePack == null) { - _logger.Here().Warning($"Copy to editable pack requested for '{CommandLoggingHelper.DescribeNode(_selectedNode)}' but no editable pack is selected"); + _logger.Here().Warning($"Copy to editable pack requested for '{CommandLoggingHelper.DescribeNode(selectedNode)}' but no editable pack is selected"); standardDialogs.ShowDialogBox("No editable pack selected!"); return; } + var container = TreeNodeHelper.GetPackFileContainer(selectedNode); + if (container == null) + { + _logger.Here().Warning($"Copy to editable pack blocked because no container was resolved for '{CommandLoggingHelper.DescribeNode(selectedNode)}'"); + standardDialogs.ShowDialogBox("Unable to resolve selected packfile"); + return; + } + using (standardDialogs.ShowWaitCursor()) { - var files = _selectedNode.GetAllChildFileNodes(); - _logger.Here().Information($"Copying {files.Count} file(s) from '{CommandLoggingHelper.DescribeNode(_selectedNode)}' to editable pack '{CommandLoggingHelper.DescribePack(editablePack)}'"); + var files = selectedNode.GetAllChildFileNodes(); + _logger.Here().Information($"Copying {files.Count} file(s) from '{CommandLoggingHelper.DescribeNode(selectedNode)}' to editable pack '{CommandLoggingHelper.DescribePack(editablePack)}'"); foreach (var file in files) - packFileService.CopyFileFromOtherPackFile(file.FileOwner, file.GetFullPath(), editablePack); + packFileService.CopyFileFromOtherPackFile(container, file.GetFullPath(), editablePack); - _logger.Here().Information($"Copied {files.Count} file(s) from '{CommandLoggingHelper.DescribeNode(_selectedNode)}' to editable pack '{CommandLoggingHelper.DescribePack(editablePack)}'"); + _logger.Here().Information($"Copied {files.Count} file(s) from '{CommandLoggingHelper.DescribeNode(selectedNode)}' to editable pack '{CommandLoggingHelper.DescribePack(editablePack)}'"); } } } 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 04108f576..9e0b050e4 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommand.cs @@ -1,23 +1,39 @@ using System.Linq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; using Shared.Core.Services; using Serilog; using Shared.Core.ErrorHandling; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class CreateFolderCommand(IStandardDialogs standardDialogs) : IContextMenuCommand + public class CreateFolderCommand(IPackFileService packFileService, IStandardDialogs standardDialogs) : IContextMenuCommand { private readonly ILogger _logger = Logging.Create(); public string GetDisplayName(TreeNode node) => "Create Folder"; - public bool ShouldAdd(TreeNode node) => node.NodeType != NodeType.File && !node.FileOwner.IsCaPackFile; + public bool ShouldAdd(TreeNode node) + { + var container = TreeNodeHelper.GetPackFileContainer(node); + return node.NodeType != NodeType.File && container is { IsCaPackFile: false }; + } + public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode selectedNode) { - if (selectedNode.FileOwner.IsCaPackFile) + var container = TreeNodeHelper.GetPackFileContainer(selectedNode); + if (container == null) + { + _logger.Here().Warning($"Create folder blocked because no container was resolved for '{CommandLoggingHelper.DescribeNode(selectedNode)}'"); + standardDialogs.ShowDialogBox("Unable to resolve selected packfile"); + return; + } + + if (container.IsCaPackFile) { - _logger.Here().Warning($"Create folder blocked for CA pack '{CommandLoggingHelper.DescribePack(selectedNode.FileOwner)}'"); + _logger.Here().Warning($"Create folder blocked for CA pack '{CommandLoggingHelper.DescribePack(container)}'"); standardDialogs.ShowDialogBox("Unable to edit CA packfile"); return; } @@ -27,7 +43,7 @@ public void Execute(TreeNode selectedNode) if (folderName.Any()) { _logger.Here().Information($"Creating folder '{folderName}' under '{CommandLoggingHelper.DescribeNode(selectedNode)}'"); - selectedNode.AddDirectoryChild(folderName); + PackFileTreeMutationService.CreateDirectoryChild(selectedNode, folderName); } else { 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 03814a376..8b388e559 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/DeleteNodeCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/DeleteNodeCommand.cs @@ -1,7 +1,9 @@ using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; using Shared.Core.Services; using Serilog; using Shared.Core.ErrorHandling; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -10,12 +12,26 @@ public class DeleteNodeCommand(IPackFileService packFileService, IStandardDialog private readonly ILogger _logger = Logging.Create(); public string GetDisplayName(TreeNode node) => "Delete"; - public bool ShouldAdd(TreeNode node) => (node.NodeType == NodeType.File || node.NodeType == NodeType.Directory) && !node.FileOwner.IsCaPackFile; + public bool ShouldAdd(TreeNode node) + { + var container = TreeNodeHelper.GetPackFileContainer(node); + var packFile = TreeNodeHelper.GetPackFile(node); + return container is { IsCaPackFile: false } && ((node.NodeType == NodeType.File && packFile != null) || node.NodeType == NodeType.Directory); + } + public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) { - if (_selectedNode.FileOwner.IsCaPackFile) + var container = TreeNodeHelper.GetPackFileContainer(_selectedNode); + if (container == null) + { + _logger.Here().Warning($"Delete blocked because no container was resolved for '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); + standardDialogs.ShowDialogBox("Unable to resolve selected packfile", "Error"); + return; + } + + if (container.IsCaPackFile) { _logger.Here().Warning($"Delete blocked for CA pack node '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); standardDialogs.ShowDialogBox("Unable to edit CA packfile", "Error"); @@ -27,13 +43,17 @@ public void Execute(TreeNode _selectedNode) { if (_selectedNode.NodeType == NodeType.File) { + var packFile = TreeNodeHelper.GetPackFile(_selectedNode); + if (packFile == null) + return; + _logger.Here().Information($"Deleting file node '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); - packFileService.DeleteFile(_selectedNode.FileOwner, _selectedNode.Item); + packFileService.DeleteFile(container, packFile); } else if (_selectedNode.NodeType == NodeType.Directory) { _logger.Here().Information($"Deleting directory node '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); - packFileService.DeleteFolder(_selectedNode.FileOwner, _selectedNode.GetFullPath()); + packFileService.DeleteFolder(container, _selectedNode.GetFullPath()); } } else 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 98b864882..40b2f62d1 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommand.cs @@ -5,6 +5,7 @@ using Shared.Core.Services; using Serilog; using Shared.Core.ErrorHandling; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -13,13 +14,23 @@ public class DuplicateFileCommand(IPackFileService packFileService, IStandardDia private readonly ILogger _logger = Logging.Create(); public string GetDisplayName(TreeNode node) => "Duplicate"; - public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null && !node.FileOwner.IsCaPackFile; + public bool ShouldAdd(TreeNode node) + { + var container = TreeNodeHelper.GetPackFileContainer(node); + var packFile = TreeNodeHelper.GetPackFile(node); + return node.NodeType == NodeType.File && packFile != null && container is { IsCaPackFile: false }; + } + public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) { + var packFile = TreeNodeHelper.GetPackFile(_selectedNode); + if (packFile == null) + return; + _logger.Here().Information($"Duplicating file node '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); - Execute(_selectedNode.Item!); + Execute(packFile); } public void Execute(PackFile item) 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 7d008a039..04d89749a 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ExpandNodeCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ExpandNodeCommand.cs @@ -1,5 +1,6 @@ using Serilog; using Shared.Core.ErrorHandling; +using Shared.Core.PackFiles.Models; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -20,7 +21,7 @@ public void Execute(TreeNode _selectedNode) void ExpandAllRecursive(TreeNode node) { node.IsNodeExpanded = true; - foreach (var child in node.BackingChildren) + foreach (var child in node.Children) 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 321ad9818..d7e245559 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommand.cs @@ -1,22 +1,33 @@ using System; using System.IO; using Shared.Core.Misc; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; using Shared.Core.Services; using Serilog; using Shared.Core.ErrorHandling; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class ExportToDirectoryCommand(IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : IContextMenuCommand + public class ExportToDirectoryCommand(IPackFileService packFileService, IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : IContextMenuCommand { private readonly ILogger _logger = Logging.Create(); 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 ShouldAdd(TreeNode node) => node.NodeType == NodeType.Directory || node.NodeType == NodeType.Root || (node.NodeType == NodeType.File && TreeNodeHelper.GetPackFile(node) != null); public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode selectedNode) { + var container = TreeNodeHelper.GetPackFileContainer(selectedNode); + if (container == null) + { + _logger.Here().Warning($"Export blocked because no container was resolved for '{CommandLoggingHelper.DescribeNode(selectedNode)}'"); + standardDialogs.ShowDialogBox("Unable to resolve selected packfile"); + return; + } + // TODO: Fix bug where if you export the packfilecontainer itself it doesn't export correctly. var folderDialogResult = standardDialogs.ShowSystemFolderBrowserDialog(); if (folderDialogResult.Result && !string.IsNullOrEmpty(folderDialogResult.FolderPath)) @@ -27,7 +38,7 @@ public void Execute(TreeNode selectedNode) ? "" : fileSystemAccess.PathGetDirectoryName(selectedNode.GetFullPath()); var fileCounter = 0; - SaveSelfAndChildren(selectedNode, folderDialogResult.FolderPath, nodeStartDir, ref fileCounter); + SaveSelfAndChildren(selectedNode, container, folderDialogResult.FolderPath, nodeStartDir, ref fileCounter); standardDialogs.ShowDialogBox($"{fileCounter} files exported!", "Export"); _logger.Here().Information($"Exported {fileCounter} file(s) from '{CommandLoggingHelper.DescribeNode(selectedNode)}' to '{folderDialogResult.FolderPath}'"); } @@ -37,12 +48,12 @@ public void Execute(TreeNode selectedNode) } } - void SaveSelfAndChildren(TreeNode node, string outputDirectory, string? rootPath, ref int fileCounter) + void SaveSelfAndChildren(TreeNode node, IPackFileContainer container, string outputDirectory, string? rootPath, ref int fileCounter) { if (node.NodeType == NodeType.Directory || node.NodeType == NodeType.Root) { - foreach (var item in node.BackingChildren) - SaveSelfAndChildren(item, outputDirectory, rootPath, ref fileCounter); + foreach (var item in node.Children) + SaveSelfAndChildren(item, container, outputDirectory, rootPath, ref fileCounter); } else { @@ -54,7 +65,10 @@ void SaveSelfAndChildren(TreeNode node, string outputDirectory, string? rootPath if (!string.IsNullOrEmpty(fileOutputDir)) DirectoryHelper.EnsureCreated(fileOutputDir); - var packFile = node.Item; + var packFile = packFileService.FindFile(node.GetFullPath(), container); + if (packFile == null) + return; + var bytes = packFile.DataSource.ReadData(); fileSystemAccess.FileWriteAllBytes(fileOutputPath, bytes); 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 fa5a8349c..1fe64c62c 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportDirectoryCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportDirectoryCommand.cs @@ -7,6 +7,7 @@ using Shared.Core.Services; using Serilog; using Shared.Core.ErrorHandling; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -15,14 +16,27 @@ public class ImportDirectoryCommand(IPackFileService packFileService, IStandardD private readonly ILogger _logger = Logging.Create(); public string GetDisplayName(TreeNode node) => "Import Directory"; - public bool ShouldAdd(TreeNode node) => node.NodeType != NodeType.File && !node.FileOwner.IsCaPackFile; + public bool ShouldAdd(TreeNode node) + { + var container = TreeNodeHelper.GetPackFileContainer(node); + return node.NodeType != NodeType.File && container is { IsCaPackFile: false }; + } + public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) { - if (_selectedNode.FileOwner.IsCaPackFile) + var container = TreeNodeHelper.GetPackFileContainer(_selectedNode); + if (container == null) + { + _logger.Here().Warning($"Import directory blocked because no container was resolved for '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); + standardDialogs.ShowDialogBox("Unable to resolve selected packfile"); + return; + } + + if (container.IsCaPackFile) { - _logger.Here().Warning($"Import directory blocked for CA pack '{CommandLoggingHelper.DescribePack(_selectedNode.FileOwner)}'"); + _logger.Here().Warning($"Import directory blocked for CA pack '{CommandLoggingHelper.DescribePack(container)}'"); standardDialogs.ShowDialogBox("Unable to edit CA packfile"); return; } @@ -62,7 +76,7 @@ public void Execute(TreeNode _selectedNode) filesAdded.Add(new NewPackFileEntry(packDirectoryPath, file)); } - packFileService.AddFilesToPack(_selectedNode.FileOwner, filesAdded); + packFileService.AddFilesToPack(container, filesAdded); _logger.Here().Information($"Imported {filesAdded.Count} file(s) from '{folderPath}' into '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); } else 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 ceddde8fa..3f245147b 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFilesCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFilesCommand.cs @@ -5,6 +5,7 @@ using Shared.Core.Services; using Serilog; using Shared.Core.ErrorHandling; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -13,14 +14,27 @@ public class ImportFileCommand(IPackFileService packFileService, IStandardDialog private readonly ILogger _logger = Logging.Create(); public string GetDisplayName(TreeNode node) => "Import File"; - public bool ShouldAdd(TreeNode node) => node.NodeType != NodeType.File && !node.FileOwner.IsCaPackFile; + public bool ShouldAdd(TreeNode node) + { + var container = TreeNodeHelper.GetPackFileContainer(node); + return node.NodeType != NodeType.File && container is { IsCaPackFile: false }; + } + public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) { - if (_selectedNode.FileOwner.IsCaPackFile) + var container = TreeNodeHelper.GetPackFileContainer(_selectedNode); + if (container == null) + { + _logger.Here().Warning($"Import file blocked because no container was resolved for '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); + standardDialogs.ShowDialogBox("Unable to resolve selected packfile"); + return; + } + + if (container.IsCaPackFile) { - _logger.Here().Warning($"Import file blocked for CA pack '{CommandLoggingHelper.DescribePack(_selectedNode.FileOwner)}'"); + _logger.Here().Warning($"Import file blocked for CA pack '{CommandLoggingHelper.DescribePack(container)}'"); standardDialogs.ShowDialogBox("Unable to edit CA packfile"); return; } @@ -34,9 +48,9 @@ public void Execute(TreeNode _selectedNode) foreach (var file in files) { var fileName = Path.GetFileName(file); - var packFile = new PackFile(fileName, new MemorySource(fileSystemAccess.FileReadAllBytes(file))); - var item = new NewPackFileEntry(parentPath, packFile); - packFileService.AddFilesToPack(_selectedNode.FileOwner, [item]); + var importedFile = new PackFile(fileName, new MemorySource(fileSystemAccess.FileReadAllBytes(file))); + var item = new NewPackFileEntry(parentPath, importedFile); + packFileService.AddFilesToPack(container, [item]); } _logger.Here().Information($"Imported {files.Count} file(s) into '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); 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 13c101d5f..20321b0d4 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInCommand.cs @@ -5,6 +5,7 @@ using Shared.Core.Services; using Serilog; using Shared.Core.ErrorHandling; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -13,7 +14,7 @@ public abstract class OpenNodeInCommand(IStandardDialogs standardDialogs, IFileS private readonly ILogger _logger = Logging.Create(); public abstract string GetDisplayName(TreeNode node); - public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && TreeNodeHelper.GetPackFile(node) != null; public bool IsEnabled(TreeNode node) => true; public abstract void Execute(TreeNode _selectedNode); @@ -48,17 +49,30 @@ protected string ResolveApplicationPath(string appRelativePath) if (fileSystemAccess.FileExists(x64)) return x64; return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), appRelativePath); } + + protected void OpenSelectedNodeUsing(TreeNode selectedNode, string applicationPath) + { + var packFile = TreeNodeHelper.GetPackFile(selectedNode); + if (packFile == null) + { + _logger.Here().Warning($"Unable to resolve file for node '{CommandLoggingHelper.DescribeNode(selectedNode)}'"); + standardDialogs.ShowDialogBox("Unable to resolve selected file"); + return; + } + + OpenPackFileUsing(applicationPath, packFile); + } } 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(ResolveApplicationPath(@"Notepad++\notepad++.exe"), _selectedNode.Item!); + public override void Execute(TreeNode _selectedNode) => OpenSelectedNodeUsing(_selectedNode, ResolveApplicationPath(@"Notepad++\notepad++.exe")); } 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(ResolveApplicationPath(@"HxD\HxD.exe"), _selectedNode.Item!); + public override void Execute(TreeNode _selectedNode) => OpenSelectedNodeUsing(_selectedNode, ResolveApplicationPath(@"HxD\HxD.exe")); } } 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 b45a56a1b..f34acce62 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenPackInFileExplorerCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenPackInFileExplorerCommand.cs @@ -1,25 +1,40 @@ using System.Diagnostics; using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; using Shared.Core.Services; using Serilog; using Shared.Core.ErrorHandling; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { - public class OpenPackInFileExplorerCommand(IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : IContextMenuCommand + public class OpenPackInFileExplorerCommand(IPackFileService packFileService, IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : IContextMenuCommand { private readonly ILogger _logger = Logging.Create(); public string GetDisplayName(TreeNode node) => "Open In File Explorer"; - public bool ShouldAdd(TreeNode node) => node.NodeType != NodeType.File && !node.FileOwner.IsCaPackFile; + public bool ShouldAdd(TreeNode node) + { + var container = TreeNodeHelper.GetPackFileContainer(node); + return node.NodeType != NodeType.File && container is { IsCaPackFile: false }; + } + public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) { - var systemFilePath = _selectedNode.FileOwner.SystemFilePath; + var container = TreeNodeHelper.GetPackFileContainer(_selectedNode); + if (container == null) + { + _logger.Here().Warning($"Open in File Explorer blocked because no container was resolved for '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); + standardDialogs.ShowDialogBox("Unable to resolve selected packfile"); + return; + } + + var systemFilePath = container.SystemFilePath; if (string.IsNullOrEmpty(systemFilePath)) { - _logger.Here().Warning($"Open in File Explorer blocked because pack '{CommandLoggingHelper.DescribePack(_selectedNode.FileOwner)}' has not been saved to disk"); + _logger.Here().Warning($"Open in File Explorer blocked because pack '{CommandLoggingHelper.DescribePack(container)}' has not been saved to disk"); standardDialogs.ShowDialogBox("Pack file has not been saved to disk yet."); return; } @@ -29,7 +44,7 @@ public void Execute(TreeNode _selectedNode) if (systemFilePath == null) { - _logger.Here().Warning($"Unable to determine folder for pack '{CommandLoggingHelper.DescribePack(_selectedNode.FileOwner)}'"); + _logger.Here().Warning($"Unable to determine folder for pack '{CommandLoggingHelper.DescribePack(container)}'"); standardDialogs.ShowDialogBox("Unable to determine folder for pack file."); return; } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/RenameNodeCommand.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/RenameNodeCommand.cs index 9e41280d3..3544c791b 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/RenameNodeCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/RenameNodeCommand.cs @@ -1,8 +1,9 @@ using System.Linq; -using Shared.Core.PackFiles; -using Shared.Core.Services; using Serilog; using Shared.Core.ErrorHandling; +using Shared.Core.PackFiles; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -11,29 +12,43 @@ public class RenameNodeCommand(IPackFileService packFileService, IStandardDialog private readonly ILogger _logger = Logging.Create(); public string GetDisplayName(TreeNode node) => "Rename"; - public bool ShouldAdd(TreeNode node) => (node.NodeType == NodeType.File || node.NodeType == NodeType.Directory) && !node.FileOwner.IsCaPackFile; + + public bool ShouldAdd(TreeNode node) + { + var container = TreeNodeHelper.GetPackFileContainer(node); + var packFile = TreeNodeHelper.GetPackFile(node); + return container is { IsCaPackFile: false } && ((node.NodeType == NodeType.File && packFile != null) || node.NodeType == NodeType.Directory); + } + public bool IsEnabled(TreeNode node) => true; - public void Execute(TreeNode _selectedNode) + public void Execute(TreeNode selectedNode) { - var FileOwner = _selectedNode.FileOwner; - if (FileOwner.IsCaPackFile) + var container = TreeNodeHelper.GetPackFileContainer(selectedNode); + if (container == null) { - _logger.Here().Warning($"Rename blocked for CA pack node '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); + _logger.Here().Warning($"Rename blocked because no container was resolved for '{CommandLoggingHelper.DescribeNode(selectedNode)}'"); + standardDialogs.ShowDialogBox("Unable to resolve selected packfile", "Error"); + return; + } + + if (container.IsCaPackFile) + { + _logger.Here().Warning($"Rename blocked for CA pack node '{CommandLoggingHelper.DescribeNode(selectedNode)}'"); standardDialogs.ShowDialogBox("Unable to edit CA packfile", "Error"); return; } - if (_selectedNode.NodeType == NodeType.Directory) + if (selectedNode.NodeType == NodeType.Directory) { - var currentPath = _selectedNode.GetFullPath(); - var inputResult = standardDialogs.ShowTextInputDialog("Create folder", _selectedNode.Name); + var currentPath = selectedNode.GetFullPath(); + var inputResult = standardDialogs.ShowTextInputDialog("Create folder", selectedNode.Name); var newFolderName = inputResult.Result ? inputResult.Text.ToLower().Trim() : string.Empty; if (newFolderName.Any()) { _logger.Here().Information($"Renaming directory '{currentPath}' to '{newFolderName}'"); - _selectedNode.Name = newFolderName; - packFileService.RenameDirectory(_selectedNode.FileOwner, currentPath, newFolderName); + selectedNode.Name = newFolderName; + packFileService.RenameDirectory(container, currentPath, newFolderName); } else { @@ -41,21 +56,24 @@ public void Execute(TreeNode _selectedNode) } } - else if (_selectedNode.NodeType == NodeType.File) + else if (selectedNode.NodeType == NodeType.File) { - var currentPath = CommandLoggingHelper.DescribeNode(_selectedNode); - var inputResult = standardDialogs.ShowTextInputDialog("Rename file", _selectedNode.Name); + var currentPath = CommandLoggingHelper.DescribeNode(selectedNode); + var inputResult = standardDialogs.ShowTextInputDialog("Rename file", selectedNode.Name); var newFileName = inputResult.Result ? inputResult.Text.ToLower().Trim() : string.Empty; if (newFileName.Any()) { + var packFile = TreeNodeHelper.GetPackFile(selectedNode); + if (packFile == null) + return; + _logger.Here().Information($"Renaming file '{currentPath}' to '{newFileName}'"); - packFileService.RenameFile(_selectedNode.FileOwner, _selectedNode.Item, newFileName); + packFileService.RenameFile(container, packFile, newFileName); } else { _logger.Here().Information($"Rename file cancelled for '{currentPath}'"); } - } } } 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 efa6d3102..6e58d50ae 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommand.cs @@ -2,9 +2,10 @@ using Serilog; using Shared.Core.ErrorHandling; using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; using Shared.Core.Services; using Shared.Core.Settings; -using Shared.Ui.Common; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -12,28 +13,40 @@ public class SaveAsPackFileContainerCommand(IPackFileService packFileService, Ap { 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 ShouldAdd(TreeNode node) + { + var container = TreeNodeHelper.GetPackFileContainer(node); + return node.NodeType == NodeType.Root && container is { IsCaPackFile: false }; + } + public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) { - var packDescription = CommandLoggingHelper.DescribePack(_selectedNode.FileOwner); - var saveDialogResult = standardDialogs.ShowSystemSaveFileDialog(_selectedNode.FileOwner.Name, "PackFile | *.pack", "pack"); + var container = TreeNodeHelper.GetPackFileContainer(_selectedNode); + if (container == null) + { + _logger.Here().Warning($"Save As blocked because no container was resolved for '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); + standardDialogs.ShowDialogBox("Unable to resolve selected packfile", "Error"); + return; + } + + var packDescription = CommandLoggingHelper.DescribePack(container); + var saveDialogResult = standardDialogs.ShowSystemSaveFileDialog(container.Name, "PackFile | *.pack", "pack"); if (!saveDialogResult.Result || string.IsNullOrEmpty(saveDialogResult.FilePath)) { _logger.Here().Information($"Save As cancelled for pack file container '{packDescription}'"); return; } - using (new WaitCursor()) + using (standardDialogs.ShowWaitCursor()) { try { var gameInformation = GameInformationDatabase.GetGameById(applicationSettingsService.CurrentSettings.CurrentGame); _logger.Here().Information($"Saving pack file container '{packDescription}' as '{saveDialogResult.FilePath}'"); - packFileService.SavePackContainer(_selectedNode.FileOwner, saveDialogResult.FilePath, false, gameInformation); - _selectedNode.UnsavedChanged = false; - _selectedNode.ForeachNode((node) => node.UnsavedChanged = false); + packFileService.SavePackContainer(container, saveDialogResult.FilePath, false, gameInformation); + (_selectedNode as RootTreeNode)?.UnsavedChanges.ClearAll(); _logger.Here().Information($"Saved pack file container '{packDescription}' as '{saveDialogResult.FilePath}'"); } catch (Exception e) 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 cbfa710eb..c660ec4c0 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommand.cs @@ -2,8 +2,10 @@ using Serilog; using Shared.Core.ErrorHandling; using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; using Shared.Core.Services; using Shared.Core.Settings; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; using Shared.Ui.Common; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands @@ -15,16 +17,29 @@ 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 ShouldAdd(TreeNode node) + { + var container = TreeNodeHelper.GetPackFileContainer(node); + return node.NodeType == NodeType.Root && container is { IsCaPackFile: false }; + } + public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode _selectedNode) { - var packDescription = CommandLoggingHelper.DescribePack(_selectedNode.FileOwner); - var systemPath = _selectedNode.FileOwner.SystemFilePath; + var container = TreeNodeHelper.GetPackFileContainer(_selectedNode); + if (container == null) + { + _logger.Here().Warning($"Save blocked because no container was resolved for '{CommandLoggingHelper.DescribeNode(_selectedNode)}'"); + standardDialogs.ShowDialogBox("Unable to resolve selected packfile", "Error"); + return; + } + + var packDescription = CommandLoggingHelper.DescribePack(container); + var systemPath = container.SystemFilePath; if (string.IsNullOrWhiteSpace(systemPath)) { - var saveDialogResult = standardDialogs.ShowSystemSaveFileDialog(_selectedNode.FileOwner.Name, "PackFile | *.pack", "pack"); + var saveDialogResult = standardDialogs.ShowSystemSaveFileDialog(container.Name, "PackFile | *.pack", "pack"); if (!saveDialogResult.Result || string.IsNullOrEmpty(saveDialogResult.FilePath)) { _logger.Here().Information($"Save cancelled for pack file container '{packDescription}'"); @@ -39,7 +54,7 @@ public void Execute(TreeNode _selectedNode) { var gameInformation = GameInformationDatabase.GetGameById(applicationSettingsService.CurrentSettings.CurrentGame); _logger.Here().Information($"Saving pack file container '{packDescription}' to '{systemPath}'"); - packFileService.SavePackContainer(_selectedNode.FileOwner, systemPath, false, gameInformation); + packFileService.SavePackContainer(container, systemPath, false, gameInformation); _logger.Here().Information($"Saved pack file container '{packDescription}' to '{systemPath}'"); } catch (Exception e) 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 2e9b12531..7c3b67f24 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommand.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommand.cs @@ -1,6 +1,8 @@ using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; using Serilog; using Shared.Core.ErrorHandling; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -9,13 +11,25 @@ public class SetAsEditablePackCommand(IPackFileService packFileService) : IConte private readonly ILogger _logger = Logging.Create(); 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 ShouldAdd(TreeNode node) + { + var container = TreeNodeHelper.GetPackFileContainer(node); + return node.NodeType == NodeType.Root && container is { IsCaPackFile: false } && packFileService.GetEditablePack() != container; + } + public bool IsEnabled(TreeNode node) => true; public void Execute(TreeNode selectedNode) { - _logger.Here().Information($"Setting pack file container '{CommandLoggingHelper.DescribePack(selectedNode.FileOwner)}' as editable"); - packFileService.SetEditablePack(selectedNode.FileOwner); + var container = TreeNodeHelper.GetPackFileContainer(selectedNode); + if (container == null) + { + _logger.Here().Warning($"Set editable pack blocked because no container was resolved for '{CommandLoggingHelper.DescribeNode(selectedNode)}'"); + return; + } + + _logger.Here().Information($"Setting pack file container '{CommandLoggingHelper.DescribePack(container)}' as editable"); + packFileService.SetEditablePack(container); } } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/ContextMenuBuilder.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/ContextMenuBuilder.cs index ce5e90f97..1f38d205d 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/ContextMenuBuilder.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/ContextMenu/ContextMenuBuilder.cs @@ -39,7 +39,8 @@ public ObservableCollection Build(ContextMenuType contextMenuTy continue; var parent = GetOrCreateMenuPath(item.Path, clusterRoot, pathToMenuLookup); - parent.ContextMenu.Add(new ContextMenuItem(command.GetDisplayName(node), () => command.Execute(node))); + var x = new ContextMenuItem(command.GetDisplayName(node), () => command.Execute(node)); + parent.ContextMenu.Add(x); } RemoveEmptySubmenus(clusterRoot); @@ -58,6 +59,8 @@ public ObservableCollection Build(ContextMenuType contextMenuTy return output; } + + private static ContextMenuItem GetOrCreateMenuPath(string path, ContextMenuItem root, Dictionary pathToMenuLookup) { if (string.IsNullOrWhiteSpace(path)) diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserView.xaml b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserView.xaml index 64e9b204e..6aeb2efdf 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserView.xaml +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserView.xaml @@ -71,7 +71,7 @@ @@ -122,10 +122,10 @@ - + - + diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserView.xaml.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserView.xaml.cs index e63342e41..64082abd4 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserView.xaml.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserView.xaml.cs @@ -106,7 +106,15 @@ private void treeView_MouseMove(object sender, MouseEventArgs e) { if (_draggedItem != null) { - DragDrop.DoDragDrop(tvParameters, tvParameters.SelectedValue, DragDropEffects.Move); + if (DataContext is PackFileBrowserViewModel viewModel) + { + var dragData = viewModel.FindPackFile(_draggedItem) ?? tvParameters.SelectedValue; + DragDrop.DoDragDrop(tvParameters, dragData, DragDropEffects.Move); + } + else + { + DragDrop.DoDragDrop(tvParameters, tvParameters.SelectedValue, DragDropEffects.Move); + } } } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserViewModel.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserViewModel.cs index 1feb0f9ad..12e46f24b 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserViewModel.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileBrowserViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.ComponentModel; using System.IO; using System.Linq; using System.Windows.Input; @@ -10,9 +11,11 @@ using Shared.Core.Events.Global; using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; -using Shared.Core.PackFiles.Utility; +using Shared.Core.Services; using Shared.Core.Settings; +using Shared.Ui.BaseDialogs.PackFileTree.Commands; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; using Shared.Ui.Common; using Shared.Ui.Common.MenuSystem; @@ -29,12 +32,12 @@ public partial class PackFileBrowserViewModel : ObservableObject, IDisposable, I private readonly ApplicationSettingsService _applicationSettingsService; private readonly PackFileContextMenuComposer _contextMenuComposer; private readonly ContextMenuType _contextMenuType; - private readonly Dictionary _treeRoots = []; + private readonly DoubleClickCommand _doubleClickCommand; public event FileSelectedDelegate FileOpen; public event NodeSelectedDelegate NodeSelected; - public ObservableCollection Files { get; set; } = []; + public ObservableCollection Files { get; set; } = []; public SearchFilter Filter { get; private set; } [ObservableProperty] TreeNode _selectedItem; @@ -42,7 +45,15 @@ public partial class PackFileBrowserViewModel : ObservableObject, IDisposable, I public bool ShowFoldersOnly { get; } - public PackFileBrowserViewModel(ApplicationSettingsService applicationSettingsService, PackFileContextMenuComposer contextMenuComposer, ContextMenuType contextMenuType, 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, + IStandardDialogs? standardDialogs = null) { _packFileService = packFileService; _eventHub = eventHub; @@ -50,6 +61,7 @@ public PackFileBrowserViewModel(ApplicationSettingsService applicationSettingsSe _applicationSettingsService = applicationSettingsService; _contextMenuComposer = contextMenuComposer; _contextMenuType = contextMenuType; + _doubleClickCommand = new DoubleClickCommand(packFileService, windowKeyboard); ShowFoldersOnly = showFoldersOnly; @@ -63,8 +75,11 @@ public PackFileBrowserViewModel(ApplicationSettingsService applicationSettingsSe _eventHub?.Register(this, x => OnPackFileContainerFolderRenamedEvent(x.Container, x.OldNodePath, x.NewNodePath)); _eventHub?.Register(this, OnPackFileContainerSavedEvent); - Filter = new SearchFilter(Files, () => _treeRoots.Values); - Filter.ShowFoldersOnly = showFoldersOnly; + Filter = new SearchFilter(Files, standardDialogs) + { + ShowFoldersOnly = showFoldersOnly + }; + foreach (var item in _packFileService.GetAllPackfileContainers()) { var loadFile = true; @@ -97,12 +112,10 @@ private void OnPackFileContainerFolderRemovedEvent(IPackFileContainer container, if (nodeToDelete == null) return; - var parentNode = nodeToDelete.Parent; - parentNode?.RemoveChild(nodeToDelete); - parentNode?.Children.Remove(nodeToDelete); - nodeToDelete.RemoveSelf(); + root.UnsavedChanges.RemoveWithDescendants(nodeToDelete); + PackFileTreeMutationService.RemoveNode(nodeToDelete); - root.UnsavedChanged = true; + root.UnsavedChanges.MarkChanged(root); Filter.Reapply(); } @@ -119,14 +132,7 @@ private void OnPackFileContainerFolderRenamedEvent(IPackFileContainer container, newLeafName = newPath.Substring(lastSep + 1); node.Name = newLeafName; - root.UnsavedChanged = true; - node.UnsavedChanged = true; - var parent = node.Parent; - while (parent != null && parent != root) - { - parent.UnsavedChanged = true; - parent = parent.Parent; - } + root.UnsavedChanges.MarkChangedWithAncestors(node, root); Filter.Reapply(); } @@ -134,16 +140,14 @@ private void OnPackFileContainerSavedEvent(PackFileContainerSavedEvent e) { var root = GetRootNode(e.Container); - TreeNodeStateHelper.ClearUnsavedOnLoadedNodes(root); - - root.ForeachNode((node) => node.UnsavedChanged = false); + root.UnsavedChanges.ClearAll(); Filter.Reapply(); } private void OnPackFileContainerFilesRemovedEvent(IPackFileContainer container, List files) { var root = GetRootNode(container); - root.UnsavedChanged = true; + root.UnsavedChanges.MarkChanged(root); foreach (var file in files) { @@ -151,10 +155,8 @@ private void OnPackFileContainerFilesRemovedEvent(IPackFileContainer container, if (node == null) continue; - var parentNode = node.Parent; - parentNode?.RemoveChild(node); - parentNode?.Children.Remove(node); - node.RemoveSelf(); + root.UnsavedChanges.Remove(node); + PackFileTreeMutationService.RemoveNode(node); } Filter.Reapply(); @@ -165,19 +167,12 @@ private void OnPackFileContainerFilesUpdatedEvent(PackFileContainerFilesUpdatedE foreach (var file in e.ChangedFiles) { var root = GetRootNode(e.Container); - root.UnsavedChanged = true; - var node = GetNodeFromPackFile(e.Container, file); + var node = GetNodeFromPackFile(e.Container, file, false) ?? FindRenamedFileNode(e.Container, file); if (node == null) continue; - node.Name = file.Name; - node.UnsavedChanged = true; - var parent = node.Parent; - while (parent != null && parent != root) - { - parent.UnsavedChanged = true; - parent = parent.Parent; - } + node.Name = file.Name; + root.UnsavedChanges.MarkChangedWithAncestors(node, root); } Filter.Reapply(); @@ -192,45 +187,18 @@ protected virtual void OnClearText() [RelayCommand] protected virtual void OnDoubleClick(TreeNode node) { - var targetNode = node ?? SelectedItem; - if (targetNode == null) - return; - - if (!ReferenceEquals(SelectedItem, targetNode)) - SelectedItem = targetNode; - - var maxExpandCount = 200; - if (targetNode.NodeType == NodeType.File) - { - FileOpen?.Invoke(targetNode.Item!); - } - else if (targetNode.NodeType == NodeType.Directory || targetNode.NodeType == NodeType.Root) - { - targetNode.IsNodeExpanded = !targetNode.IsNodeExpanded; - - if (_windowKeyboard.IsKeyDown(Key.LeftCtrl)) - { - var numChildren = targetNode.GetAllChildFileNodes().Count; - if (numChildren < maxExpandCount) - targetNode.ExpandIfVisible(true); - } - } + _doubleClickCommand.Execute(node, SelectedItem, n => SelectedItem = n, file => FileOpen?.Invoke(file)); } private void OnMainEditablePackChanged(PackFileContainerSetAsMainEditableEvent e) { foreach (var item in Files) - item.IsMainEditabelPack = false; - - var newContiner = Files.FirstOrDefault(x => x.FileOwner == e.Container); - if (newContiner != null) - newContiner.IsMainEditabelPack = true; + item.IsMainEditabelPack = item.Owner == e.Container; } private void OnPackFileContainerFilesAddedEvent(IPackFileContainer container, List files) { var root = GetRootNode(container); - root.UnsavedChanged = true; foreach (var item in files) { @@ -249,34 +217,21 @@ private void OnPackFileContainerFilesAddedEvent(IPackFileContainer container, Li TreeNode newNode; if (numSeperators == 0) { - // 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); + PackFileTreeMutationService.RemoveExistingFileNode(root, item.Name); + newNode = new TreeNode(item.Name, NodeType.File, root); + PackFileTreeMutationService.InsertChildSorted(root, newNode); } else { var directory = fullPath.Substring(0, directoryEnd); var folder = GetNodeFromPath(root, directory)!; + PackFileTreeMutationService.RemoveExistingFileNode(folder, item.Name); - // 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 = new TreeNode(item.Name, NodeType.File, folder); + PackFileTreeMutationService.InsertChildSorted(folder, newNode); } - newNode.UnsavedChanged = true; - var parent = newNode.Parent; - while (parent != null && parent != root) - { - parent.UnsavedChanged = true; - parent = parent.Parent; - } + root.UnsavedChanges.MarkChangedWithAncestors(newNode, root); } Filter.Reapply(); @@ -284,23 +239,14 @@ private void OnPackFileContainerFilesAddedEvent(IPackFileContainer container, Li private TreeNode? GetNodeFromPath(TreeNode parent, string path, bool createIfMissing = true) { - var numSeperators = path.Count(x => x == Path.DirectorySeparatorChar); if (path.Length == 0) return parent; - parent.EnsureChildrenPopulated(); - - var nodeName = path; - var remainingStr = ""; + var currentIndex = path.IndexOf(Path.DirectorySeparatorChar, 0); + var nodeName = currentIndex == -1 ? path : path.Substring(0, currentIndex); + var remainingStr = currentIndex == -1 ? string.Empty : path.Substring(currentIndex + 1); - 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.BackingChildren) + foreach (var child in parent.Children) { if (child.Name == nodeName && child.NodeType == NodeType.Directory) return GetNodeFromPath(child, remainingStr, createIfMissing); @@ -308,9 +254,7 @@ private void OnPackFileContainerFilesAddedEvent(IPackFileContainer container, Li if (createIfMissing) { - var newNode = new TreeNode(nodeName, NodeType.Directory, parent.FileOwner, parent, () => Filter.HasActiveFilter); - newNode.MarkChildrenLoaded(); - TreeNodeManipulationHelper.InsertChildSorted(parent, newNode); + var newNode = PackFileTreeMutationService.CreateDirectoryChild(parent, nodeName); return GetNodeFromPath(newNode, remainingStr, createIfMissing); } return null; @@ -321,12 +265,11 @@ private void OnPackFileContainerFilesAddedEvent(IPackFileContainer container, Li var root = GetRootNode(container); var fullPath = _packFileService.GetFullPath(pf, container); var numSeperators = fullPath.Count(x => x == Path.DirectorySeparatorChar); - - root.EnsureChildrenPopulated(); + var fileName = pf.Name; if (numSeperators == 0) { - return root.BackingChildren.FirstOrDefault(x => x.Item == pf); + return root.Children.FirstOrDefault(x => x.NodeType == NodeType.File && x.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase)); } else { @@ -334,107 +277,96 @@ private void OnPackFileContainerFilesAddedEvent(IPackFileContainer container, Li var directory = fullPath.Substring(0, directoryEnd); var parent = GetNodeFromPath(root, directory, createIfMissing); - return parent?.BackingChildren.FirstOrDefault(x => x.Item == pf); + return parent?.Children.FirstOrDefault(x => x.NodeType == NodeType.File && x.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase)); } } + private TreeNode? FindRenamedFileNode(IPackFileContainer container, PackFile file) + { + var root = GetRootNode(container); + var fullPath = _packFileService.GetFullPath(file, container); + var directoryEnd = fullPath.LastIndexOf(Path.DirectorySeparatorChar); + var parent = directoryEnd == -1 + ? root + : GetNodeFromPath(root, fullPath.Substring(0, directoryEnd), false); + if (parent == null) + return null; + + return parent.Children.FirstOrDefault(child => + child.NodeType == NodeType.File && + !child.Name.Equals(file.Name, StringComparison.OrdinalIgnoreCase) && + FindPackFile(child) == null); + } + private void ReloadTree(IPackFileContainer container) { - if (_treeRoots.TryGetValue(container, out var existingRoot)) + foreach (var file in Files) { - Files.Remove(existingRoot); - _treeRoots.Remove(container); + if (file.Owner == container) + { + Files.Remove(file); + break; + } } var skipWemFiles = container.IsCaPackFile && _applicationSettingsService.CurrentSettings.ShowCAWemFiles == false; - var root = new TreeNode(container.Name, NodeType.Root, container, null, () => Filter.HasActiveFilter); - root.SetChildLoader(node => LoadChildrenFromContainer(node, container, skipWemFiles)); + var root = new RootTreeNode(container.Name, container); + PackFileTreeBuilder.BuildTreeFromFiles(root, container, skipWemFiles); root.IsMainEditabelPack = _packFileService.GetEditablePack() == container; - _treeRoots[container] = root; Files.Add(root); Filter.Reapply(); } - private bool LoadChildrenFromContainer(TreeNode node, IPackFileContainer container, bool skipWemFiles) + private void OnPackFileContainerRemoved(PackFileContainerRemovedEvent e) { - var directoryPath = node.GetFullPath(); - var split = PackFileServiceUtility.SplitDirectoryEntries(container, directoryPath); - - foreach (var folderName in split.SubFolders) + foreach (var file in Files) { - var childNode = new TreeNode(folderName, NodeType.Directory, container, node, () => Filter.HasActiveFilter); - childNode.SetChildLoader(n => LoadChildrenFromContainer(n, container, skipWemFiles)); - node.AddChild(childNode); + if (file.Owner == e.Container) + { + Files.Remove(file); + break; + } } + } - 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 TreeNode(fileName, NodeType.File, container, node, file); - node.AddChild(fileNode); - } + public bool AllowDrop(TreeNode node, TreeNode? targetNode = null) => DropHandler.AllowDrop(node, targetNode, _packFileService); - return true; - } + public bool Drop(TreeNode node, TreeNode? targeNode) => DropHandler.Drop(node, targeNode, _packFileService); - private void OnPackFileContainerRemoved(PackFileContainerRemovedEvent e) + private RootTreeNode GetRootNode(IPackFileContainer container) { - if (_treeRoots.TryGetValue(e.Container, out var root)) + foreach (var node in Files) { - Files.Remove(root); - _treeRoots.Remove(e.Container); + if (node.Owner == container) + { + return node; + } } + + throw new Exception("Unable to find root node from Container where name = " + container.Name); } - public bool AllowDrop(TreeNode node, TreeNode? targetNode = null) + public IPackFileContainer? FindFileOwner(TreeNode? node) { - if (targetNode == null) - return false; - - if (node.Item == null) - return false; - - if (node.FileOwner != targetNode.FileOwner) - return false; - - if (node.FileOwner.IsCaPackFile) - return false; - - if (targetNode.Item != null) - return false; + if (node == null) + return null; - return true; + var root = TreeNodeHelper.GetRootNode(node); + return root.Owner; } - public bool Drop(TreeNode node, TreeNode? targeNode) + public PackFile? FindPackFile(TreeNode? node) { - if (targeNode == null) - return false; - - var container = node.FileOwner; - var draggedFile = node.Item; - var dropPath = targeNode.GetFullPath(); - - var newFullPath = string.IsNullOrWhiteSpace(dropPath) - ? draggedFile.Name - : dropPath + "\\" + draggedFile.Name; - if (newFullPath == _packFileService.GetFullPath(draggedFile, container)) - return false; + if (node == null || node.NodeType != NodeType.File) + return null; - _packFileService.MoveFile(container, draggedFile, dropPath); + var container = FindFileOwner(node); + if (container == null) + return null; - return true; - } - - private TreeNode GetRootNode(IPackFileContainer container) - { - return _treeRoots[container]; + return _packFileService.FindFile(node.GetFullPath(), container); } } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileTreeViewFactory.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileTreeViewFactory.cs index dfa79f7b5..0637ad923 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileTreeViewFactory.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/PackFileTreeViewFactory.cs @@ -1,5 +1,6 @@ using Shared.Core.Events; using Shared.Core.PackFiles; +using Shared.Core.Services; using Shared.Core.Settings; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu; using Shared.Ui.Common.MenuSystem; @@ -13,6 +14,7 @@ public class PackFileTreeViewFactory private readonly IEventHub _eventHub; private readonly PackFileContextMenuComposer _contextMenuComposer; private readonly IWindowsKeyboard _windowKeyboard; + //private readonly IStandardDialogs _standardDialogs; public PackFileTreeViewFactory(ApplicationSettingsService applicationSettingsService, IPackFileService packFileService, IEventHub eventHub, PackFileContextMenuComposer contextMenuComposer, IWindowsKeyboard windowKeyboard) { @@ -21,11 +23,12 @@ public PackFileTreeViewFactory(ApplicationSettingsService applicationSettingsSer _eventHub = eventHub; _contextMenuComposer = contextMenuComposer; _windowKeyboard = windowKeyboard; + // _standardDialogs = standardDialogs; } public PackFileBrowserViewModel Create(ContextMenuType contextMenu, bool showCaFiles, bool showFoldersOnly) { - var fileTree = new PackFileBrowserViewModel(_applicationSettingsService, _contextMenuComposer, contextMenu, _packFileService, _eventHub, _windowKeyboard, showCaFiles, showFoldersOnly); + var fileTree = new PackFileBrowserViewModel(_applicationSettingsService, _contextMenuComposer, contextMenu, _packFileService, _eventHub, _windowKeyboard, showCaFiles, showFoldersOnly, null); return fileTree; } } diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/SearchFilter.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/SearchFilter.cs index de2fca6ec..8a7eff7a4 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/SearchFilter.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/SearchFilter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Shared.Core.Misc; using Shared.Core.PackFiles.Models; +using Shared.Core.Services; namespace Shared.Ui.BaseDialogs.PackFileTree { @@ -15,11 +16,11 @@ public class SearchFilter : NotifyPropertyChangedImpl, IDataErrorInfo, IDisposab public string Error { get; set; } = string.Empty; public string this[string columnName] => ApplyFilter(FilterText); - private readonly ObservableCollection _nodeCollection; - private readonly Func> _rootNodesFactory; - private readonly List _nodesPopulatedByFilter = []; + private readonly ObservableCollection _rootNodes; + private readonly IStandardDialogs? _standardDialogs; private CancellationTokenSource? _debounceCts; + private readonly object _debounceLock = new(); private const int DebounceMilliseconds = 250; private bool _useDebounce; @@ -59,18 +60,22 @@ public bool ShowFoldersOnly public bool HasActiveFilter => !string.IsNullOrWhiteSpace(FilterText) || ShowFoldersOnly || (_extensionFilter?.Count > 0); public Action? FilterCleared { get; set; } - internal SearchFilter(ObservableCollection nodes, Func> rootNodesFactory) + internal SearchFilter(ObservableCollection nodes, IStandardDialogs? standardDialogs = null) { - _nodeCollection = nodes; - _rootNodesFactory = rootNodesFactory; + _rootNodes = nodes; + _standardDialogs = standardDialogs; } private async void DebounceFilter() { - _debounceCts?.Cancel(); - _debounceCts?.Dispose(); - _debounceCts = new CancellationTokenSource(); - var token = _debounceCts.Token; + CancellationToken token; + lock (_debounceLock) + { + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + _debounceCts = new CancellationTokenSource(); + token = _debounceCts.Token; + } try { @@ -86,17 +91,8 @@ private async void DebounceFilter() 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); + using var _ = _standardDialogs?.ShowWaitCursor(); + var rootNodes = _rootNodes.ToList(); if (HasActiveFilter) { @@ -107,9 +103,8 @@ string ApplyFilter(string text) { foreach (var rootNode in rootNodes) { - var container = rootNode.FileOwner; + var container = rootNode.Owner; var matchingFiles = container.SearchFiles(textFilter, _extensionFilter); - RebuildTreeFromSearchResults(rootNode, matchingFiles); } } @@ -120,42 +115,29 @@ string ApplyFilter(string text) ApplyFoldersOnlyFilter(rootNode); } - foreach (var rootNode in _nodeCollection) - rootNode.RefreshLoadedBranch(); - if (AutoExapandResultsAfterLimitedCount != -1) { var visibleNodes = 0; foreach (var item in rootNodes) visibleNodes += CountVisibleNodes(item); - foreach (var item in _nodeCollection) - { + foreach (var item in rootNodes) item.ExpandForFilter(); - item.EnsureChildrenLoaded(); - } if (visibleNodes <= AutoExapandResultsAfterLimitedCount) { - foreach (var item in _nodeCollection) + foreach (var item in rootNodes) item.ExpandIfVisible(markAsFilterExpansion: true); } } } else { - // Filter cleared — restore lazy tree state foreach (var rootNode in rootNodes) - RestoreFullTree(rootNode); + SetVisibilityRecursive(rootNode, true); - foreach (var rootNode in _nodeCollection) - rootNode.RefreshLoadedBranch(); - - foreach (var item in _nodeCollection) - { + foreach (var item in rootNodes) item.AbsorbFilterExpansion(); - item.NormalizeLazyState(); - } FilterCleared?.Invoke(); } @@ -184,110 +166,49 @@ private void RebuildTreeFromSearchResults(TreeNode rootNode, List<(string Path, return; // Build the visible tree directly from paths — no container calls - foreach (var (path, file) in matchingFiles) + foreach (var (path, _) in matchingFiles) { - MarkPathVisibleFromData(rootNode, path, file); + MarkPathVisibleFromData(rootNode, path); } } - private void MarkPathVisibleFromData(TreeNode rootNode, string filePath, PackFile file) + private void MarkPathVisibleFromData(TreeNode rootNode, string filePath) { var current = rootNode; - var segments = filePath.Split('\\'); + var segments = filePath.Split('\\', StringSplitOptions.RemoveEmptyEntries); + current.IsVisible = true; - // 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; - var segmentName = segments[i]; - var child = current.BackingChildren.FirstOrDefault( + var child = current.Children.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); - } + return; + child.IsVisible = true; 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); + var fileNode = current.Children.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; - } - - /// - /// 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) - { - 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); - } - - /// - /// 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() - { - foreach (var node in _nodesPopulatedByFilter) - { - node.BackingChildren.Clear(); - node.ResetChildrenLoaded(); - } - _nodesPopulatedByFilter.Clear(); + fileNode.IsVisible = true; } private static void SetVisibilityRecursive(TreeNode node, bool visible) { - node.IsVisible = visible; - if (node.ChildrenLoaded) - { - foreach (var child in node.BackingChildren) - SetVisibilityRecursive(child, visible); - } - } + if (node.IsVisible != visible) + node.IsVisible = visible; - private static void RestoreFullTree(TreeNode node) - { - node.IsVisible = true; - if (node.ChildrenLoaded) - { - foreach (var child in node.BackingChildren) - RestoreFullTree(child); - } + foreach (var child in node.Children) + SetVisibilityRecursive(child, visible); } private static void ApplyFoldersOnlyFilter(TreeNode node) @@ -299,11 +220,8 @@ private static void ApplyFoldersOnlyFilter(TreeNode node) } node.IsVisible = true; - if (node.ChildrenLoaded) - { - foreach (var child in node.BackingChildren) - ApplyFoldersOnlyFilter(child); - } + foreach (var child in node.Children) + ApplyFoldersOnlyFilter(child); } private static int CountVisibleNodes(TreeNode node) @@ -312,25 +230,12 @@ private static int CountVisibleNodes(TreeNode node) return 1; var count = 0; - if (node.ChildrenLoaded) - { - foreach (var child in node.BackingChildren) - count += CountVisibleNodes(child); - } + foreach (var child in node.Children) + count += CountVisibleNodes(child); return count; } - private static void SetChildVisibilityPredicateRecursive(TreeNode node, Func? predicate) - { - node.SetChildVisibilityPredicate(predicate); - if (node.ChildrenLoaded) - { - foreach (var child in node.BackingChildren) - SetChildVisibilityPredicateRecursive(child, predicate); - } - } - public void Dispose() { _debounceCts?.Cancel(); diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNode.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNode.cs index 7b691e560..7e3f42dbb 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNode.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/TreeNode.cs @@ -1,8 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; -using System.Diagnostics; using CommunityToolkit.Mvvm.ComponentModel; using Serilog; using Shared.Core.ErrorHandling; @@ -17,131 +16,68 @@ public enum NodeType File } + public partial class RootTreeNode : TreeNode + { + public IPackFileContainer Owner { get; } + public UnsavedChangesTracker UnsavedChanges { get; } = new(); + + [ObservableProperty] public partial bool IsMainEditabelPack { get; set; } + + public RootTreeNode(string name, IPackFileContainer owner) : + base(name, NodeType.Root, null) + { + + Owner = owner; + } + } + public partial class TreeNode : ObservableObject { - private static readonly ILogger s_logger = Logging.Create(); - 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; + public bool HasChildren => Children.Count > 0; [ObservableProperty] public partial ObservableCollection Children { get; set; } = []; - [ObservableProperty] public partial bool UnsavedChanged { get; set; } - [ObservableProperty] public partial bool IsMainEditabelPack { get; set; } [ObservableProperty] public partial bool IsVisible { get; set; } = true; [ObservableProperty] public partial string Name { get; set; } [ObservableProperty] public partial bool IsNodeExpanded { get; set; } = false; [ObservableProperty] public partial NodeType NodeType { get; private set; } - internal bool HasMaterializedChildren => Children.Any(x => x._isPlaceholder == false); + public bool UnsavedChanged => Utility.TreeNodeHelper.GetRootNode(this)?.UnsavedChanges.IsChanged(this) ?? false; - public TreeNode(string name, NodeType type, IPackFileContainer owner, TreeNode? parent, PackFile? packFile = null) - { - Name = name; - Item = packFile; - FileOwner = owner; - Parent = parent; - NodeType = type; - - if (string.IsNullOrWhiteSpace(name)) - { - s_logger.Here().Error($"Encountered empty tree node name. Owner:'{owner.Name}', Parent:'{DescribeNode(parent)}'"); - 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"); - } + public void NotifyUnsavedChangedChanged() => OnPropertyChanged(nameof(UnsavedChanged)); - if (ShouldUsePlaceholder()) - AddPlaceholderChild(); - } - - internal TreeNode(string name, NodeType type, IPackFileContainer owner, TreeNode? parent, Func isFilterActive, PackFile? packFile = null) + public TreeNode(string name, NodeType type, TreeNode? parent) { - _isFilterActive = isFilterActive; Name = name; - Item = packFile; - FileOwner = owner; Parent = parent; NodeType = type; if (string.IsNullOrWhiteSpace(name)) { - s_logger.Here().Error($"Encountered empty tree node name. Owner:'{owner.Name}', Parent:'{DescribeNode(parent)}'"); 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(); - } - - private TreeNode(TreeNode parent) - { - _isPlaceholder = true; - Name = ""; - FileOwner = parent.FileOwner; - Parent = parent; - NodeType = NodeType.File; - IsVisible = false; - } - - public void SetChildLoader(Func childLoader) - { - _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); - s_logger.Here().Information($"Populated children for node '{DescribeNode(this)}' (Loaded:{_childrenLoaded}, BackingChildren:{BackingChildren.Count})"); - } - - public void EnsureFullyPopulated() - { - EnsureChildrenPopulated(); - foreach (var child in BackingChildren) - child.EnsureFullyPopulated(); } public void AddChild(TreeNode child) { child.Parent = this; - BackingChildren.Add(child); + Children.Add(child); } public void RemoveChild(TreeNode child) { - BackingChildren.Remove(child); + Children.Remove(child); child.Parent = null; } + internal void SetChildren(List children) + { + Children = new ObservableCollection(children); + } + public string GetFullPath() { if (NodeType == NodeType.Root) @@ -176,11 +112,10 @@ public IEnumerable EnumerateAllNodesDepthFirst() while (stack.Count > 0) { var current = stack.Pop(); - current.EnsureChildrenPopulated(); yield return current; - for (var i = current.BackingChildren.Count - 1; i >= 0; i--) - stack.Push(current.BackingChildren[i]); + for (var i = current.Children.Count - 1; i >= 0; i--) + stack.Push(current.Children[i]); } } @@ -195,145 +130,35 @@ public IEnumerable EnumerateFileNodesDepthFirst() public void RemoveSelf() { - foreach (var child in Children) + foreach (var child in Children.ToList()) child.RemoveSelf(); Children.Clear(); Parent = null; - - // Clear closures and delegates to break reference cycles - _childLoader = null; - _childVisibilityPredicate = null; } public void ForeachNode(Action func) { func.Invoke(this); foreach (var child in Children) - { - if (child._isPlaceholder) - continue; - child.ForeachNode(func); - } } public void ExpandIfVisible(bool includeChildren = true, bool markAsFilterExpansion = false) { - if (IsVisible) - { - if (markAsFilterExpansion) - ExpandForFilter(); - else - IsNodeExpanded = true; - - MaterializeChildren(); - if (includeChildren) - { - foreach (var child in Children) - { - if (child._isPlaceholder) - continue; - - child.ExpandIfVisible(includeChildren); - } - } - } - } - - public TreeNode AddDirectoryChild(string name) - { - EnsureChildrenPopulated(); - var newNode = new TreeNode(name, NodeType.Directory, FileOwner, this, _isFilterActive ?? (() => false)); - newNode.MarkChildrenLoaded(); - InsertChildSorted(this, newNode); - - if (HasMaterializedChildren || IsNodeExpanded || (_isFilterActive?.Invoke() ?? false)) - MaterializeChildren(); - - if (Children.Count == 1 && Children[0]._isPlaceholder) - MaterializeChildren(); - - s_logger.Here().Information($"Added directory node '{DescribeNode(newNode)}' under '{DescribeNode(this)}'"); - - return newNode; - } - - internal void RefreshLoadedBranch() - { - if (!HasMaterializedChildren) + if (!IsVisible) return; - MaterializeChildren(); - foreach (var child in Children) - { - if (child._isPlaceholder) - continue; - - child.RefreshLoadedBranch(); - } - } - - internal void MaterializeChildren() - { - EnsureChildrenPopulated(); + if (markAsFilterExpansion) + ExpandForFilter(); + else + IsNodeExpanded = true; - if (!HasChildren) + if (!includeChildren) return; - // Propagate the visibility predicate to newly loaded children - if (_childVisibilityPredicate != null) - { - foreach (var child in BackingChildren) - { - if (child._childVisibilityPredicate != _childVisibilityPredicate) - child._childVisibilityPredicate = _childVisibilityPredicate; - } - } - - var childNodes = BackingChildren - .Where(ShouldMaterializeChild) - .ToList(); - - Children.Clear(); - foreach (var child in childNodes) - { - child.Parent = this; - Children.Add(child); - - if (child.IsNodeExpanded && !child.HasMaterializedChildren) - child.MaterializeChildren(); - } - - if (Children.Count == 0 && ShouldUsePlaceholder()) - AddPlaceholderChild(); - - var materializedChildren = Children.Count(x => !x._isPlaceholder); - s_logger.Here().Information($"Materialized {materializedChildren} child node(s) for '{DescribeNode(this)}' (BackingChildren:{BackingChildren.Count}, FilterActive:{_isFilterActive?.Invoke() == true})"); - } - - internal void EnsureChildrenLoaded() - { - MaterializeChildren(); - } - - internal void NormalizeLazyState() - { - if (IsNodeExpanded) - { - MaterializeChildren(); - foreach (var child in Children) - { - if (child._isPlaceholder) - continue; - - child.NormalizeLazyState(); - } - } - else - { - UnloadChildren(); - } + foreach (var child in Children) + child.ExpandIfVisible(includeChildren, markAsFilterExpansion); } internal void ExpandForFilter() @@ -347,12 +172,7 @@ internal void ExpandForFilter() internal void ClearFilterExpansion() { foreach (var child in Children) - { - if (child._isPlaceholder) - continue; - child.ClearFilterExpansion(); - } if (_isExpandedByFilter) { @@ -364,121 +184,11 @@ internal void ClearFilterExpansion() internal void AbsorbFilterExpansion() { foreach (var child in Children) - { - if (child._isPlaceholder) - continue; - child.AbsorbFilterExpansion(); - } _isExpandedByFilter = false; } - partial void OnIsNodeExpandedChanged(bool value) - { - if (value) - MaterializeChildren(); - else - UnloadChildren(); - - LogLoadedNodeCount(value); - } - - private void UnloadChildren() - { - var materializedChildren = Children.Count(x => !x._isPlaceholder); - if (materializedChildren > 0 || BackingChildren.Count > 0) - s_logger.Here().Information($"Unloading child nodes for '{DescribeNode(this)}' (MaterializedChildren:{materializedChildren}, BackingChildren:{BackingChildren.Count})"); - - // 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(TreeNode child) - { - if (_isFilterActive?.Invoke() != true) - return _childVisibilityPredicate?.Invoke(child) ?? true; - - if (_childVisibilityPredicate != null && !_childVisibilityPredicate(child)) - return false; - - return child.IsVisible; - } - - private bool ShouldUsePlaceholder() - { - return HasChildren && !IsNodeExpanded && (_isFilterActive?.Invoke() != true); - } - - private void AddPlaceholderChild() - { - if (Children.Any(x => x._isPlaceholder)) - return; - - Children.Add(new TreeNode(this)); - } - - public int CountLoadedNodes() - { - var count = 1; - foreach (var child in Children) - { - if (child._isPlaceholder) - continue; - - count += child.CountLoadedNodes(); - } - - return count; - } - - private void LogLoadedNodeCount(bool expanded) - { - var root = this; - while (root.Parent != null) - root = root.Parent; - - var loadedNodes = root.CountLoadedNodes(); - var action = expanded ? "expanded" : "collapsed"; - s_logger.Here().Information($"Tree node '{DescribeNode(this)}' {action}. Loaded nodes in tree: {loadedNodes}"); - } - - private static string DescribeNode(TreeNode? node) - { - if (node == null) - return ""; - - if (node.NodeType == NodeType.Root) - return ""; - - var path = node.GetFullPath(); - return string.IsNullOrWhiteSpace(path) ? node.Name : path; - } - - 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/UnsavedChangesTracker.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/UnsavedChangesTracker.cs new file mode 100644 index 000000000..3d6863a08 --- /dev/null +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/UnsavedChangesTracker.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; + +namespace Shared.Ui.BaseDialogs.PackFileTree +{ + public class UnsavedChangesTracker + { + private readonly HashSet _changedNodes = []; + + public bool IsChanged(TreeNode node) => _changedNodes.Contains(node); + + public void MarkChanged(TreeNode node) + { + if (_changedNodes.Add(node)) + node.NotifyUnsavedChangedChanged(); + } + + public void MarkChangedWithAncestors(TreeNode node, TreeNode root) + { + MarkChanged(root); + MarkChanged(node); + + var parent = node.Parent; + while (parent != null && parent != root) + { + MarkChanged(parent); + parent = parent.Parent; + } + } + + public void ClearAll() + { + var nodesToNotify = new List(_changedNodes); + _changedNodes.Clear(); + + foreach (var node in nodesToNotify) + node.NotifyUnsavedChangedChanged(); + } + + public void Remove(TreeNode node) + { + if (_changedNodes.Remove(node)) + node.NotifyUnsavedChangedChanged(); + } + + public void RemoveWithDescendants(TreeNode node) + { + var stack = new Stack(); + stack.Push(node); + + while (stack.Count > 0) + { + var current = stack.Pop(); + _changedNodes.Remove(current); + + for (var i = current.Children.Count - 1; i >= 0; i--) + stack.Push(current.Children[i]); + } + } + + public bool HasChanges => _changedNodes.Count > 0; + } +} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/DropHandler.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/DropHandler.cs new file mode 100644 index 000000000..e1a710f65 --- /dev/null +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/DropHandler.cs @@ -0,0 +1,80 @@ +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; + +namespace Shared.Ui.BaseDialogs.PackFileTree.Utility +{ + public static class DropHandler + { + public static bool AllowDrop(TreeNode node, TreeNode? targetNode, IPackFileService packFileService) + { + if (targetNode == null) + return false; + + if (node.NodeType != NodeType.File) + return false; + + var sourceContainer = FindFileOwner(node); + var targetContainer = FindFileOwner(targetNode); + if (sourceContainer == null || sourceContainer != targetContainer) + return false; + + if (sourceContainer.IsCaPackFile) + return false; + + if (targetNode.NodeType == NodeType.File) + return false; + + if (FindPackFile(node, packFileService) == null) + return false; + + return true; + } + + public static bool Drop(TreeNode node, TreeNode? targetNode, IPackFileService packFileService) + { + if (targetNode == null) + return false; + + var container = FindFileOwner(node); + if (container == null) + return false; + + var draggedFile = FindPackFile(node, packFileService); + if (draggedFile == null) + return false; + + var dropPath = targetNode.GetFullPath(); + + var newFullPath = string.IsNullOrWhiteSpace(dropPath) + ? draggedFile.Name + : dropPath + "\\" + draggedFile.Name; + if (newFullPath == packFileService.GetFullPath(draggedFile, container)) + return false; + + packFileService.MoveFile(container, draggedFile, dropPath); + + return true; + } + + private static IPackFileContainer? FindFileOwner(TreeNode? node) + { + if (node == null) + return null; + + var root = Utility.TreeNodeHelper.GetRootNode(node); + return root.Owner; + } + + private static PackFile? FindPackFile(TreeNode? node, IPackFileService packFileService) + { + if (node == null || node.NodeType != NodeType.File) + return null; + + var container = FindFileOwner(node); + if (container == null) + return null; + + return packFileService.FindFile(node.GetFullPath(), container); + } + } +} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/PackFileTreeBuilder.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/PackFileTreeBuilder.cs new file mode 100644 index 000000000..5363e8731 --- /dev/null +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/PackFileTreeBuilder.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using Shared.Core.PackFiles.Models; + +namespace Shared.Ui.BaseDialogs.PackFileTree.Utility +{ + public static class PackFileTreeBuilder + { + private readonly record struct PathPrefixKey(string Path, int Length) + { + public static readonly PathPrefixKey Empty = new(string.Empty, 0); + + public ReadOnlySpan Span => Path.AsSpan(0, Length); + } + + private sealed class PathPrefixKeyComparer : IEqualityComparer + { + public static readonly PathPrefixKeyComparer Ordinal = new(); + + public bool Equals(PathPrefixKey x, PathPrefixKey y) + { + return x.Length == y.Length && x.Span.SequenceEqual(y.Span); + } + + public int GetHashCode(PathPrefixKey obj) + { + var hash = new HashCode(); + foreach (var ch in obj.Span) + hash.Add(ch); + + return hash.ToHashCode(); + } + } + + private 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); + }; + + public static void BuildTreeFromFiles(TreeNode root, IPackFileContainer container, bool skipWemFiles) + { + var allFiles = container.GetAllFiles(); + var filesByFolder = GroupFilesByFolder(allFiles, skipWemFiles); + var directoryMap = new Dictionary(filesByFolder.Count + 1, PathPrefixKeyComparer.Ordinal) + { + [PathPrefixKey.Empty] = root + }; + var childrenByParent = new Dictionary>(filesByFolder.Count + 1); + var pendingDirectories = new List<(string FolderName, PathPrefixKey FullFolderPath)>(8); + + foreach (var folderPath in filesByFolder.Keys) + { + if (folderPath.Length == 0) + continue; + + EnsureDirectoryPath(root, folderPath, directoryMap, pendingDirectories, childrenByParent); + } + + foreach (var folderEntry in filesByFolder) + { + var parentNode = directoryMap[folderEntry.Key]; + foreach (var file in folderEntry.Value) + { + var fileNode = new TreeNode(file.Name, NodeType.File, parentNode); + AddChildForBuild(parentNode, fileNode, childrenByParent); + } + } + + FinalizeTree(root, childrenByParent); + } + + private static Dictionary> GroupFilesByFolder(Dictionary allFiles, bool skipWemFiles) + { + var filesByFolder = new Dictionary>(PathPrefixKeyComparer.Ordinal) + { + [PathPrefixKey.Empty] = [] + }; + + foreach (var item in allFiles) + { + var path = item.Key; + if (skipWemFiles && path.EndsWith(".wem", StringComparison.OrdinalIgnoreCase)) + continue; + + var separatorIndex = FindLastDirectorySeparatorIndex(path.AsSpan()); + var folderPath = separatorIndex == -1 + ? PathPrefixKey.Empty + : new PathPrefixKey(path, separatorIndex); + + ref var files = ref CollectionsMarshal.GetValueRefOrAddDefault(filesByFolder, folderPath, out _); + files ??= []; + files.Add(item.Value); + } + + return filesByFolder; + } + + private static TreeNode EnsureDirectoryPath(TreeNode root, PathPrefixKey folderPath, Dictionary directoryMap, List<(string FolderName, PathPrefixKey FullFolderPath)> pendingDirectories, Dictionary> childrenByParent) + { + if (directoryMap.TryGetValue(folderPath, out var existingDirectory)) + return existingDirectory; + + pendingDirectories.Clear(); + var currentFolderPath = folderPath; + while (currentFolderPath.Length > 0 && !directoryMap.TryGetValue(currentFolderPath, out existingDirectory)) + { + var currentPathSpan = currentFolderPath.Span; + var separatorIndex = FindLastDirectorySeparatorIndex(currentPathSpan); + var folderName = separatorIndex == -1 + ? currentFolderPath.Path[..currentFolderPath.Length] + : currentFolderPath.Path.Substring(separatorIndex + 1, currentFolderPath.Length - separatorIndex - 1); + pendingDirectories.Add((folderName, currentFolderPath)); + currentFolderPath = separatorIndex == -1 + ? PathPrefixKey.Empty + : new PathPrefixKey(currentFolderPath.Path, separatorIndex); + } + + var parentNode = currentFolderPath.Length == 0 ? root : directoryMap[currentFolderPath]; + for (var i = pendingDirectories.Count - 1; i >= 0; i--) + { + var currentDirectory = pendingDirectories[i]; + var currentNode = new TreeNode(currentDirectory.FolderName, NodeType.Directory, parentNode); + AddChildForBuild(parentNode, currentNode, childrenByParent); + directoryMap[currentDirectory.FullFolderPath] = currentNode; + parentNode = currentNode; + } + + return parentNode; + } + + private static void AddChildForBuild(TreeNode parent, TreeNode child, Dictionary> childrenByParent) + { + child.Parent = parent; + + ref var children = ref CollectionsMarshal.GetValueRefOrAddDefault(childrenByParent, parent, out _); + children ??= []; + children.Add(child); + } + + private static int FindLastDirectorySeparatorIndex(ReadOnlySpan path) + { + return path.LastIndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + private static void FinalizeTree(TreeNode node, Dictionary> childrenByParent) + { + if (!childrenByParent.TryGetValue(node, out var children) || children.Count == 0) + return; + + children.Sort(TreeNodeComparison); + node.SetChildren(children); + + foreach (var child in children) + FinalizeTree(child, childrenByParent); + } + } +} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/PackFileTreeMutationService.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/PackFileTreeMutationService.cs new file mode 100644 index 000000000..ab4d4be06 --- /dev/null +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/PackFileTreeMutationService.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Shared.Ui.BaseDialogs.PackFileTree.Utility +{ + public class PackFileTreeMutationService + { + 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); + }; + + public static TreeNode CreateDirectoryChild(TreeNode parent, string name) + { + var newNode = new TreeNode(name, NodeType.Directory, parent); + InsertChildSorted(parent, newNode); + return newNode; + } + + public static void InsertChildSorted(TreeNode parent, TreeNode child) + { + var children = parent.Children; + var index = BinarySearchInsertIndex(children, child); + children.Insert(index, child); + } + + public static void RemoveExistingFileNode(TreeNode parent, string fileName) + { + var existingFile = parent.Children.FirstOrDefault(node => + node.NodeType == NodeType.File && + node.Name == fileName); + + if (existingFile == null) + return; + + RemoveNode(existingFile); + } + + public static void RemoveNode(TreeNode node) + { + var parent = node.Parent; + parent?.RemoveChild(node); + node.RemoveSelf(); + } + + private static int BinarySearchInsertIndex(ObservableCollection children, TreeNode newChild) + { + var lo = 0; + var hi = children.Count - 1; + + while (lo <= hi) + { + var mid = lo + (hi - lo) / 2; + var cmp = ChildComparison(children[mid], newChild); + if (cmp <= 0) + lo = mid + 1; + else + hi = mid - 1; + } + + return lo; + } + } +} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeHelper.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeHelper.cs new file mode 100644 index 000000000..f4b2732de --- /dev/null +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeHelper.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Utility; + +namespace Shared.Ui.BaseDialogs.PackFileTree.Utility +{ + public static class TreeNodeHelper + { + public static PackFile? GetPackFile(TreeNode? node) + { + if (node == null || node.NodeType != NodeType.File) + return null; + + var container = GetPackFileContainer(node); + return container?.FindFile(node.GetFullPath()); + } + + public static IPackFileContainer? GetPackFileContainer(TreeNode? node) + { + var root = GetRootNode(node); + return (root as RootTreeNode)?.Owner; + } + + public static RootTreeNode? GetRootNode(TreeNode? node) + { + var current = node; + while (current?.Parent != null) + current = current.Parent; + + return current as RootTreeNode; + } + + /// + /// Recursively searches for a node in the tree 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? FindInTree(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 : FindInTree(child, remainingPath); + } + + internal static TreeNode FindNode(PackFileBrowserViewModel viewModel, IPackFileContainer container, string fullPathName) + { + var root = viewModel.Files.First(x=>(x as RootTreeNode)!.Owner == container); + + var normalizedPath = PathNormalization.NormalizeFileName(fullPathName); + var splits = normalizedPath.Split('\\'); + + TreeNode? currentNode = root; + for (var i = 0; i < splits.Length; i++) + { + var nextNode = currentNode.Children.Where(x => x.Name == splits[i]).FirstOrDefault(); + if (nextNode == null) + throw new Exception("Could not find node for path: " + fullPathName); + + + currentNode = nextNode; + } + + return currentNode; + } + } +} diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeManipulationHelper.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeManipulationHelper.cs deleted file mode 100644 index 3b68680a8..000000000 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeManipulationHelper.cs +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 9cefcd4fe..000000000 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodePathHelper.cs +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index cc1ce18d4..000000000 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/PackFileTree/Utility/TreeNodeStateHelper.cs +++ /dev/null @@ -1,23 +0,0 @@ -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/PackFileWindows/PackFileBrowserWindow.xaml.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/PackFileWindows/PackFileBrowserWindow.xaml.cs index c2b5d74a5..a8cab6dbf 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/PackFileWindows/PackFileBrowserWindow.xaml.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/PackFileWindows/PackFileBrowserWindow.xaml.cs @@ -51,7 +51,7 @@ private void ViewModel_FileOpen(Core.PackFiles.Models.PackFile file) private void Button_Click(object sender, RoutedEventArgs e) { - SelectedFile = ViewModel.SelectedItem?.Item; + SelectedFile = ViewModel.FindPackFile(ViewModel.SelectedItem); if (ViewModel.SelectedItem?.NodeType == NodeType.Directory) SelectedFolder = GetFolderPath(ViewModel.SelectedItem, ViewModel.SelectedItem?.Name); diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/PackFileWindows/SavePackFileWindow.xaml.cs b/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/PackFileWindows/SavePackFileWindow.xaml.cs index a34e51de7..14de46a74 100644 --- a/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/PackFileWindows/SavePackFileWindow.xaml.cs +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/PackFileWindows/SavePackFileWindow.xaml.cs @@ -41,13 +41,12 @@ public SavePackFileWindow(IPackFileService packfileService, PackFileTreeViewFact private void ViewModel_FileSelected(TreeNode node) { _selectedNode = node; + SelectedFile = ViewModel.FindPackFile(node); - if (_selectedNode.Item == null) + if (SelectedFile == null) CurrentFileName = ""; else - CurrentFileName = _selectedNode.Item.Name; - - SelectedFile = _selectedNode.Item; + CurrentFileName = SelectedFile.Name; } private void ViewModel_FileOpen(Core.PackFiles.Models.PackFile file) diff --git a/Shared/SharedUI/Shared.Ui/DependencyInjectionContainer.cs b/Shared/SharedUI/Shared.Ui/DependencyInjectionContainer.cs index 286a2889e..5957f5c05 100644 --- a/Shared/SharedUI/Shared.Ui/DependencyInjectionContainer.cs +++ b/Shared/SharedUI/Shared.Ui/DependencyInjectionContainer.cs @@ -5,6 +5,7 @@ using Shared.Ui.BaseDialogs.PackFileTree; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; using Shared.Ui.BaseDialogs.StandardDialog; using Shared.Ui.BaseDialogs.ToolSelector; using Shared.Ui.Common.MenuSystem; @@ -24,7 +25,6 @@ public override void Register(IServiceCollection services) services.AddScoped(); services.AddSingleton(); services.AddTransient(); - services.AddScoped(); // Context menu diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/Commands/DoubleClickCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/Commands/DoubleClickCommandTests.cs new file mode 100644 index 000000000..1e5e04dff --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/Commands/DoubleClickCommandTests.cs @@ -0,0 +1,170 @@ +using System.Windows.Input; +using Shared.Core.PackFiles.Models; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.Commands; +using Shared.UiTest.BaseDialogs.PackFileTree.Utility; +using Test.TestingUtility.Shared; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.Commands +{ + [TestFixture] + internal class DoubleClickCommandTests : PackFileTreeTestBase + { + private TestKeyboard _keyboard; + private DoubleClickCommand _command; + + [SetUp] + public void SetupCommand() + { + _keyboard = new TestKeyboard(); + _command = new DoubleClickCommand(_packFileService, _keyboard); + } + + [Test] + public void DoubleClickFile_OpensFile() + { + AddPackFiles(false, "myPack", @"c:\myPack.pack", @"foldera\file1.txt"); + + var browser = PackFileBrowser(); + var root = browser.Files[0]; + var file1 = PackFileBrowserViewModelTestHelper.GetFromPath(root, @"foldera\file1.txt"); + Assert.That(file1, Is.Not.Null); + + PackFile? openedFile = null; + _command.Execute(file1, null, _ => { }, f => openedFile = f); + + Assert.That(openedFile, Is.Not.Null); + Assert.That(openedFile!.Name, Is.EqualTo("file1.txt")); + } + + [Test] + public void DoubleClickDirectory_TogglesExpansion() + { + AddPackFiles(false, "myPack", @"c:\myPack.pack", @"foldera\file1.txt"); + + var browser = PackFileBrowser(); + var root = browser.Files[0]; + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null); + Assert.That(folderA!.IsNodeExpanded, Is.False); + + _command.Execute(folderA, null, _ => { }, _ => { }); + + Assert.That(folderA.IsNodeExpanded, Is.True); + + _command.Execute(folderA, null, _ => { }, _ => { }); + + Assert.That(folderA.IsNodeExpanded, Is.False); + } + + [Test] + public void DoubleClickDirectory_SetsSelectedItem() + { + AddPackFiles(false, "myPack", @"c:\myPack.pack", @"foldera\file1.txt"); + + var browser = PackFileBrowser(); + var root = browser.Files[0]; + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + + TreeNode? selectedNode = null; + _command.Execute(folderA, null, n => selectedNode = n, _ => { }); + + Assert.That(selectedNode, Is.SameAs(folderA)); + } + + [Test] + public void CtrlDoubleClickDirectory_ExpandsAllChildren() + { + AddPackFiles(false, "myPack", @"c:\myPack.pack", + @"foldera\suba\file1.txt", + @"foldera\subb\file2.txt"); + + var browser = PackFileBrowser(); + var root = browser.Files[0]; + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null); + + _keyboard.SetKeyDown(Key.LeftCtrl, true); + _command.Execute(folderA, root, _ => { }, _ => { }); + + 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); + Assert.That(subB, Is.Not.Null); + Assert.That(subA!.IsNodeExpanded, Is.True); + Assert.That(subB!.IsNodeExpanded, Is.True); + } + + [Test] + public void CtrlDoubleClickDirectory_OnlyCountsVisibleNodes() + { + AddPackFiles(false, "myPack", @"c:\myPack.pack", + @"foldera\file1.txt", + @"foldera\file2.txt", + @"foldera\file3.txt"); + + var browser = PackFileBrowser(); + var root = browser.Files[0]; + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + Assert.That(folderA, Is.Not.Null); + + // Hide two of the three files + var file2 = PackFileBrowserViewModelTestHelper.GetFromPath(root, @"foldera\file2.txt"); + var file3 = PackFileBrowserViewModelTestHelper.GetFromPath(root, @"foldera\file3.txt"); + file2!.IsVisible = false; + file3!.IsVisible = false; + + // Use a command with MaxExpandCount effectively = 2 (only 1 visible file, under threshold) + _keyboard.SetKeyDown(Key.LeftCtrl, true); + _command.Execute(folderA, root, _ => { }, _ => { }); + + // Should expand because only 1 visible file node (below MaxExpandCount of 200) + Assert.That(folderA!.IsNodeExpanded, Is.True); + } + + [Test] + public void NullNode_WithNullSelectedItem_DoesNothing() + { + PackFile? openedFile = null; + TreeNode? selected = null; + + _command.Execute(null, null, n => selected = n, f => openedFile = f); + + Assert.That(openedFile, Is.Null); + Assert.That(selected, Is.Null); + } + + [Test] + public void CtrlDoubleClickDirectory_DoesNotExpandWhenOverMaxCount() + { + AddPackFiles(false, "myPack", @"c:\myPack.pack", + @"foldera\folderb\file01.txt", + @"foldera\folderb\file02.txt", + @"foldera\folderb\file03.txt", + @"foldera\folderb\file04.txt", + @"foldera\folderb\file05.txt", + @"foldera\folderb\file06.txt", + @"foldera\folderb\file07.txt", + @"foldera\folderb\file08.txt", + @"foldera\folderb\file09.txt", + @"foldera\folderb\file10.txt", + @"foldera\folderb\file11.txt"); + + var browser = PackFileBrowser(); + var root = browser.Files[0]; + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + var folderB = PackFileBrowserViewModelTestHelper.GetFromPath(root, @"foldera\folderb"); + Assert.That(folderA, Is.Not.Null); + Assert.That(folderB, Is.Not.Null); + + _command.MaxExpandCount = 10; + _keyboard.SetKeyDown(Key.LeftCtrl, true); + _command.Execute(folderA, root, _ => { }, _ => { }); + + Assert.That(folderA!.IsNodeExpanded, Is.True, "FolderA should toggle expanded on double-click"); + Assert.That(folderB!.IsNodeExpanded, Is.False, "FolderB should NOT expand because file count exceeds MaxExpandCount"); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommandTests.cs index 75dd44d3d..be5ab55d2 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ClosePackContainerFileCommandTests.cs @@ -1,9 +1,8 @@ -using Moq; -using Shared.Core.PackFiles; -using Shared.Core.PackFiles.Models; +using Moq; using Shared.Core.Services; using Shared.Ui.BaseDialogs.PackFileTree; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -13,21 +12,25 @@ 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); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var fileNode = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); + + var command = new ClosePackContainerFileCommand(_packFileService, new Mock().Object); Assert.That(command.ShouldAdd(root), Is.True); - Assert.That(command.ShouldAdd(file), Is.False); + Assert.That(command.ShouldAdd(fileNode), 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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new ClosePackContainerFileCommand(_packFileService, new Mock().Object); Assert.That(command.IsEnabled(root), Is.True); } @@ -35,17 +38,20 @@ public void IsEnabled_ReturnsTrue() [Test] public void Execute_ConfirmsAndUnloadsPack() { - var owner = CreateContainer(); - var root = new TreeNode("root", NodeType.Root, owner, null); - var service = new Mock(); + // Arrange + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var dialogs = new Mock(); dialogs.Setup(x => x.ShowYesNoBox(It.IsAny(), It.IsAny())).Returns(ShowMessageBoxResult.OK); - var command = new ClosePackContainerFileCommand(service.Object, dialogs.Object); - + // Act + var command = new ClosePackContainerFileCommand(_packFileService, dialogs.Object); command.Execute(root); - service.Verify(x => x.UnloadPackContainer(owner), Times.Once); + // Assert + Assert.That(viewModel.Files.Count, Is.EqualTo(0)); } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommandTests.cs index 144faf6cc..79ab8ea73 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CollapseNodeCommandTests.cs @@ -1,6 +1,6 @@ -using Shared.Core.PackFiles.Models; using Shared.Ui.BaseDialogs.PackFileTree; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -10,10 +10,12 @@ 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 container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["folder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var folder = root.Children.First(x => x.NodeType == NodeType.Directory); + var file = TreeNodeHelper.FindNode(viewModel, container, "folder\\file.txt"); + var command = new CollapseNodeCommand(); Assert.That(command.ShouldAdd(folder), Is.True); @@ -23,9 +25,11 @@ public void ShouldAdd_ReturnsTrueForFolderAndFalseForFile() [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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["folder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var folder = root.Children.First(x => x.NodeType == NodeType.Directory); + var command = new CollapseNodeCommand(); Assert.That(command.IsEnabled(folder), Is.True); @@ -34,36 +38,27 @@ public void IsEnabled_ReturnsTrue() [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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["folder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); root.IsNodeExpanded = true; - folder.IsNodeExpanded = true; - file.IsNodeExpanded = true; + root.Children.First().IsNodeExpanded = true; var command = new CollapseNodeCommand(); - command.Execute(root); Assert.That(root.IsNodeExpanded, Is.False); } [Test] - public void Execute_CollapsesUnmaterializedChildren() + public void Execute_CollapsesNestedChildren() { - 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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["folder\\nested\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var folder = root.Children.First(); + var nested = folder.Children.First(x => x.NodeType == NodeType.Directory); root.IsNodeExpanded = true; folder.IsNodeExpanded = true; diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ContextMenuCommandTestBase.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ContextMenuCommandTestBase.cs index 5cf4cf9c8..c61dfea0a 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ContextMenuCommandTestBase.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ContextMenuCommandTestBase.cs @@ -1,9 +1,20 @@ -using Moq; +using System.IO; +using System.Reflection.Metadata; +using Moq; +using Shared.Core.DependencyInjection; +using Shared.Core.Events; +using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.Settings; +using Shared.Core.ToolCreation; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { - internal abstract class ContextMenuCommandTestBase + internal abstract class ContextMenuCommandTestBase : PackFileTreeTestBase { protected static IPackFileContainer CreateContainer(bool isCa = false, string name = "pack", string systemFilePath = "C:\\temp\\pack.pack") { @@ -13,5 +24,34 @@ protected static IPackFileContainer CreateContainer(bool isCa = false, string na container.SetupProperty(x => x.IsCaPackFile, isCa); return container.Object; } + + protected static Mock CreatePackFileService(IPackFileContainer? container = null, PackFile? packFile = null) + { + var service = new Mock(); + service.Setup(x => x.GetAllPackfileContainers()).Returns(container == null ? [] : [container]); + + if (container != null && packFile != null) + service.Setup(x => x.GetPackFileContainer(packFile)).Returns(container); + + return service; + } + + protected static TreeNode CreateRoot(IPackFileContainer container) => new RootTreeNode(container.Name, container); + + protected static TreeNode CreateNodePath(TreeNode root, string path, NodeType leafType = NodeType.File) + { + var segments = path.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries); + var current = root; + + for (var i = 0; i < segments.Length; i++) + { + var nodeType = i == segments.Length - 1 ? leafType : NodeType.Directory; + var child = new TreeNode(segments[i], nodeType, current); + current.AddChild(child); + current = child; + } + + return current; + } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommandTests.cs index 536e103f0..055b883a4 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyNodePathCommandTests.cs @@ -1,9 +1,7 @@ -using System.Threading; -using Moq; -using Shared.Core.PackFiles; -using Shared.Core.PackFiles.Models; +using System.Threading; using Shared.Ui.BaseDialogs.PackFileTree; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -13,10 +11,11 @@ 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); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["folder\\file.txt"]); + var viewModel = PackFileBrowser(); + var file = TreeNodeHelper.FindNode(viewModel, container, "folder\\file.txt"); + + var command = new CopyNodePathCommand(); Assert.That(command.ShouldAdd(file), Is.True); } @@ -24,10 +23,11 @@ public void ShouldAdd_ReturnsTrueForFile() [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); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["folder\\file.txt"]); + var viewModel = PackFileBrowser(); + var file = TreeNodeHelper.FindNode(viewModel, container, "folder\\file.txt"); + + var command = new CopyNodePathCommand(); Assert.That(command.IsEnabled(file), Is.True); } @@ -36,14 +36,11 @@ public void IsEnabled_ReturnsTrue() [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); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["folder\\file.txt"]); + var viewModel = PackFileBrowser(); + var file = TreeNodeHelper.FindNode(viewModel, container, "folder\\file.txt"); + var command = new CopyNodePathCommand(); 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 index b59e16c8e..195611beb 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyToEditablePackCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CopyToEditablePackCommandTests.cs @@ -1,10 +1,8 @@ -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; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -14,12 +12,14 @@ 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); + var source = AddPackFiles(false, "source", "c:\\source.pack", ["folder\\file.txt"]); + var target = AddPackFiles(false, "target", "c:\\target.pack", []); + _packFileService.SetEditablePack(target); + + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, source, "folder\\file.txt"); + + var command = new CopyToEditablePackCommand(_packFileService, new Mock().Object); Assert.That(command.ShouldAdd(node), Is.True); } @@ -27,9 +27,11 @@ public void ShouldAdd_ReturnsTrueWhenEditablePackExists() [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); + var source = AddPackFiles(false, "source", "c:\\source.pack", ["folder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, source, "folder\\file.txt"); + + var command = new CopyToEditablePackCommand(_packFileService, new Mock().Object); Assert.That(command.IsEnabled(node), Is.True); } @@ -37,26 +39,26 @@ public void IsEnabled_ReturnsTrue() [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); + // Arrange + var source = AddPackFiles(false, "source", "c:\\source.pack", ["folder\\file.txt"]); + var target = AddPackFiles(false, "target", "c:\\target.pack", []); + _packFileService.SetEditablePack(target); + + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(x => (x as RootTreeNode)!.Owner == source); + var folder = root.Children.First(x => x.NodeType == NodeType.Directory); 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); - + // Act + var command = new CopyToEditablePackCommand(_packFileService, dialogs.Object); command.Execute(folder); - service.Verify(x => x.CopyFileFromOtherPackFile(source, It.IsAny(), target), Times.Once); + // Assert + var copiedFile = target.FindFile("folder\\file.txt"); + Assert.That(copiedFile, Is.Not.Null); } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommandTests.cs index 258b13df7..91de76ab5 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/CreateFolderCommandTests.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using Moq; +using Moq; using Shared.Core.Services; using Shared.Ui.BaseDialogs.PackFileTree; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; @@ -14,9 +11,11 @@ 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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new CreateFolderCommand(_packFileService, new Mock().Object); Assert.That(command.ShouldAdd(root), Is.True); } @@ -24,9 +23,11 @@ public void ShouldAdd_ReturnsTrueForEditableRoot() [Test] public void IsEnabled_ReturnsTrue() { - var owner = CreateContainer(); - var root = new TreeNode("root", NodeType.Root, owner, null); - var command = new CreateFolderCommand(new Mock().Object); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new CreateFolderCommand(_packFileService, new Mock().Object); Assert.That(command.IsEnabled(root), Is.True); } @@ -34,19 +35,20 @@ public void IsEnabled_ReturnsTrue() [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); + // Arrange + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); var dialogs = new Mock(); dialogs.Setup(x => x.ShowFolderNameDialog(It.IsAny>(), It.IsAny())).Returns("new_folder"); - var command = new CreateFolderCommand(dialogs.Object); - + // Act + var command = new CreateFolderCommand(_packFileService, dialogs.Object); command.Execute(root); - Assert.That(root.BackingChildren.Any(x => x.Name == "new_folder"), Is.True); + // Assert + Assert.That(root.Children.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 index 5aec83984..e25a16099 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/DeleteNodeCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/DeleteNodeCommandTests.cs @@ -1,10 +1,9 @@ -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; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -14,42 +13,66 @@ 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); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); - Assert.That(command.ShouldAdd(file), Is.True); + var command = new DeleteNodeCommand(_packFileService, new Mock().Object); + + Assert.That(command.ShouldAdd(node), 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); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); + + var command = new DeleteNodeCommand(_packFileService, new Mock().Object); - Assert.That(command.IsEnabled(file), Is.True); + Assert.That(command.IsEnabled(node), 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); + // Arrange + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); - 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); + // Act + var command = new DeleteNodeCommand(_packFileService, dialogs.Object); + command.Execute(node); + + // Assert + var packFile = container.FindFile("rootfolder\\file.txt"); + Assert.That(packFile, Is.Null); + } + + [Test] + public void Execute_DeletesDirectoryAfterConfirmation() + { + // Arrange + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["myfolder\\file1.txt", "myfolder\\file2.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var dirNode = root.Children.First(x => x.NodeType == NodeType.Directory); + + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowYesNoBox(It.IsAny(), It.IsAny())).Returns(ShowMessageBoxResult.OK); - command.Execute(file); + // Act + var command = new DeleteNodeCommand(_packFileService, dialogs.Object); + command.Execute(dirNode); - service.Verify(x => x.DeleteFile(owner, file.Item!), Times.Once); + // Assert + Assert.That(container.FindFile("myfolder\\file1.txt"), Is.Null); + Assert.That(container.FindFile("myfolder\\file2.txt"), Is.Null); } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommandTests.cs index c461b343a..7a33f4b77 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/DuplicateFileCommandTests.cs @@ -1,9 +1,9 @@ -using System.Threading; -using Shared.Core.PackFiles.Models; +using Moq; using Shared.Core.PackFiles; -using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Core.PackFiles.Models; +using Shared.Core.Services; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; -using Test.TestingUtility.Shared; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -13,14 +13,11 @@ 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(); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["animation\\meta\\testfile.anm"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "animation\\meta\\testfile.anm"); + + var command = new DuplicateFileCommand(_packFileService, new Mock().Object); Assert.That(command.ShouldAdd(node), Is.True); } @@ -28,14 +25,11 @@ public void ShouldAdd_ReturnsTrueForFileNode() [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(); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["animation\\meta\\testfile.anm"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "animation\\meta\\testfile.anm"); + + var command = new DuplicateFileCommand(_packFileService, new Mock().Object); Assert.That(command.IsEnabled(node), Is.True); } @@ -43,43 +37,44 @@ public void IsEnabled_ReturnsTrue() [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); + // Arrange + var sourceContainer = AddPackFiles(false, "SourcePack", "c:\\source.pack", ["animation\\meta\\testfile.anm"]); + var outputContainer = AddPackFiles(false, "OutputPack", "c:\\output.pack", []); + _packFileService.SetEditablePack(outputContainer); - 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(); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, sourceContainer, "animation\\meta\\testfile.anm"); + // Act + var command = new DuplicateFileCommand(_packFileService, new Mock().Object); command.Execute(node); - var foundFile = runner.PackFileService.FindFile("Animation\\Meta\\testFile_copy.anm", outputPackFile); + // Assert + var foundFile = outputContainer.FindFile("animation\\meta\\testfile_copy.anm"); 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 + [TestCase("testfile", "testfile_copy")] + [TestCase("testfile.anm", "testfile_copy.anm")] + [TestCase("testfile.anm.meta", "testfile_copy.anm.meta")] 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); + // Arrange + var sourceContainer = AddPackFiles(false, "SourcePack", "c:\\source.pack", ["animation\\meta\\" + fileName]); + var outputContainer = AddPackFiles(false, "OutputPack", "c:\\output.pack", []); + _packFileService.SetEditablePack(outputContainer); + + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, sourceContainer, "animation\\meta\\" + fileName); - var fileEntry = new NewPackFileEntry("Animation\\Meta", PackFile.CreateFromASCII(fileName, "DummyContent")); - runner.PackFileService.AddFilesToPack(sourcePackFile, [fileEntry]); - var fileToCopy = runner.PackFileService.FindFile("Animation\\Meta\\" + fileName, sourcePackFile); + // Act + var command = new DuplicateFileCommand(_packFileService, new Mock().Object); + command.Execute(node); - runner.CommandFactory.Create().Execute(fileToCopy); - var foundFile = runner.PackFileService.FindFile("Animation\\Meta\\" + result, outputPackFile); + // Assert + var foundFile = outputContainer.FindFile("animation\\meta\\" + result); 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 index 8d1f693ea..fe3ccee0f 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ExpandNodeCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ExpandNodeCommandTests.cs @@ -1,7 +1,6 @@ -using System.Threading; -using Shared.Core.PackFiles.Models; using Shared.Ui.BaseDialogs.PackFileTree; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -11,10 +10,12 @@ 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 container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["folder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var folder = root.Children.First(x => x.NodeType == NodeType.Directory); + var file = TreeNodeHelper.FindNode(viewModel, container, "folder\\file.txt"); + var command = new ExpandNodeCommand(); Assert.That(command.ShouldAdd(folder), Is.True); @@ -24,9 +25,11 @@ public void ShouldAdd_ReturnsTrueForFolderAndFalseForFile() [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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["folder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var folder = root.Children.First(x => x.NodeType == NodeType.Directory); + var command = new ExpandNodeCommand(); Assert.That(command.IsEnabled(folder), Is.True); @@ -35,38 +38,28 @@ public void IsEnabled_ReturnsTrue() [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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["folder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); root.IsNodeExpanded = false; - folder.IsNodeExpanded = false; - file.IsNodeExpanded = false; + root.Children.First().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); + Assert.That(root.Children.First().IsNodeExpanded, Is.True); } [Test] - public void Execute_ExpandsUnmaterializedChildren() + public void Execute_ExpandsNestedChildren() { - 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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["folder\\nested\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var folder = root.Children.First(); + var nested = folder.Children.First(x => x.NodeType == NodeType.Directory); root.IsNodeExpanded = false; folder.IsNodeExpanded = false; diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommandTests.cs index 334934297..db02fd2ea 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ExportToDirectoryCommandTests.cs @@ -1,11 +1,7 @@ -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; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -15,9 +11,11 @@ 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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new ExportToDirectoryCommand(_packFileService, new Mock().Object, new Mock().Object); Assert.That(command.ShouldAdd(root), Is.True); } @@ -25,33 +23,38 @@ public void ShouldAdd_ReturnsTrueForRoot() [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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new ExportToDirectoryCommand(_packFileService, new Mock().Object, new Mock().Object); Assert.That(command.IsEnabled(root), Is.True); } [Test] - public void Execute_IgnoredUntilFilesystemPassTwo() + public void Execute_DialogCancelled_DoesNotExport() { - var owner = CreateContainer(); - var root = new TreeNode("root", NodeType.Root, owner, null); + // Arrange + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + 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); + // Act + var command = new ExportToDirectoryCommand(_packFileService, dialogs.Object, fileSystem.Object); command.Execute(root); + // Assert 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")); } @@ -66,50 +69,37 @@ public void ComputeRelativePath_NullRootReturnsFullPath() [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(); - + // Arrange + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["models\\mesh1.mesh", "models\\mesh2.mesh"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + 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 => { + .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 => { + .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); + // Act + var command = new ExportToDirectoryCommand(_packFileService, dialogs.Object, fileSystem.Object); command.Execute(root); - // Verify files were written + // Assert 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 index e677f2ab1..81091dbff 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportDirectoryCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportDirectoryCommandTests.cs @@ -1,9 +1,5 @@ -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; @@ -16,16 +12,24 @@ 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); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["dir\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var dirNode = root.Children.First(x => x.NodeType == NodeType.Directory); + + var command = new ImportDirectoryCommand(_packFileService, new Mock().Object, new Mock().Object); + + Assert.That(command.ShouldAdd(dirNode), 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); + AddPackFiles(true, "gamefile", "root", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new ImportDirectoryCommand(_packFileService, new Mock().Object, new Mock().Object); Assert.That(command.IsEnabled(root), Is.True); } @@ -33,43 +37,49 @@ public void IsEnabled_ReturnsTrue() [Test] public void Execute_CaPackShowsErrorAndDoesNotImport() { - var owner = CreateContainer(isCa: true); - var root = new TreeNode("root", NodeType.Root, owner, null); + // Arrange + AddPackFiles(true, "gamefile", "root", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); - var service = new Mock(); var dialogs = new Mock(); - var command = new ImportDirectoryCommand(service.Object, dialogs.Object, new Mock().Object); + // Act + var command = new ImportDirectoryCommand(_packFileService, dialogs.Object, new Mock().Object); command.Execute(root); + // Assert 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); + // Arrange + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); - 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); + // Act + var command = new ImportDirectoryCommand(_packFileService, dialogs.Object, fileSystem.Object); command.Execute(root); - service.Verify(x => x.AddFilesToPack(It.IsAny(), It.IsAny>()), Times.Never); + // Assert fileSystem.Verify(x => x.FileReadAllBytes(It.IsAny()), Times.Never); } [Test] - public void Execute_DirectorySelected_ImportsFilesWithMockedReads() + public void Execute_DirectorySelected_ImportsFiles() { - var owner = CreateContainer(isCa: false); - var root = new TreeNode("root", NodeType.Root, owner, null); + // Arrange + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\existing.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); var folderPath = "C:\\test\\folder"; var file1Path = "C:\\test\\folder\\file1.txt"; @@ -77,7 +87,6 @@ public void Execute_DirectorySelected_ImportsFilesWithMockedReads() 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)); @@ -85,18 +94,22 @@ public void Execute_DirectorySelected_ImportsFilesWithMockedReads() 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.PathGetFileName("file1.txt")).Returns("file1.txt"); + fileSystem.Setup(x => x.PathGetFileName("subdir\\file2.txt")).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); + // Act + var command = new ImportDirectoryCommand(_packFileService, dialogs.Object, fileSystem.Object); command.Execute(root); + // Assert 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); + var importedFile1 = container.FindFile("folder\\file1.txt"); + var importedFile2 = container.FindFile("folder\\subdir\\file2.txt"); + Assert.That(importedFile1, Is.Not.Null); + Assert.That(importedFile2, Is.Not.Null); } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFileCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFileCommandTests.cs index bdb6d64a3..e685687d4 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFileCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/ImportFileCommandTests.cs @@ -1,12 +1,8 @@ -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; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -16,16 +12,24 @@ 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); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["dir\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var dirNode = root.Children.First(x => x.NodeType == NodeType.Directory); + + var command = new ImportFileCommand(_packFileService, new Mock().Object, new Mock().Object); + + Assert.That(command.ShouldAdd(dirNode), 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); + AddPackFiles(true, "gamefile", "root", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new ImportFileCommand(_packFileService, new Mock().Object, new Mock().Object); Assert.That(command.IsEnabled(root), Is.True); } @@ -33,61 +37,68 @@ public void IsEnabled_ReturnsTrue() [Test] public void Execute_CaPackShowsErrorAndDoesNotImport() { - var owner = CreateContainer(isCa: true); - var root = new TreeNode("root", NodeType.Root, owner, null); + // Arrange + AddPackFiles(true, "gamefile", "root", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); - var service = new Mock(); var dialogs = new Mock(); var fileSystem = new Mock(); - var command = new ImportFileCommand(service.Object, dialogs.Object, fileSystem.Object); + // Act + var command = new ImportFileCommand(_packFileService, dialogs.Object, fileSystem.Object); command.Execute(root); + // Assert 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); + // Arrange + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); - 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); + // Act + var command = new ImportFileCommand(_packFileService, dialogs.Object, fileSystem.Object); command.Execute(root); - service.Verify(x => x.AddFilesToPack(It.IsAny(), It.IsAny>()), Times.Never); + // Assert fileSystem.Verify(x => x.FileReadAllBytes(It.IsAny()), Times.Never); } [Test] - public void Execute_FileSelected_ImportsFileWithMockedRead() + public void Execute_FileSelected_ImportsFile() { - var owner = CreateContainer(isCa: false); - var root = new TreeNode("root", NodeType.Root, owner, null); + // Arrange + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\existing.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); 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); + // Act + var command = new ImportFileCommand(_packFileService, dialogs.Object, fileSystem.Object); command.Execute(root); + // Assert fileSystem.Verify(x => x.FileReadAllBytes(filePath), Times.Once); - service.Verify(x => x.AddFilesToPack(owner, It.Is>(items => - items.Count == 1)), Times.Once); + var importedFile = container.FindFile("file.txt"); + Assert.That(importedFile, Is.Not.Null); } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInHxDCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInHxDCommandTests.cs index cd506d795..6873915bf 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInHxDCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInHxDCommandTests.cs @@ -1,9 +1,7 @@ -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; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -13,38 +11,40 @@ 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 container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); + var command = new OpenNodeInHxDCommand(new Mock().Object, new Mock().Object); - Assert.That(command.ShouldAdd(file), Is.True); + Assert.That(command.ShouldAdd(node), 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 container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); + var command = new OpenNodeInHxDCommand(new Mock().Object, new Mock().Object); - Assert.That(command.IsEnabled(file), Is.True); + Assert.That(command.IsEnabled(node), 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 container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); 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); + command.Execute(node); 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); @@ -53,18 +53,18 @@ public void Execute_AppMissing_ShowsError() [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 container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); 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); + command.Execute(node); - fileSystem.Verify(x => x.FileWriteAllBytes(It.IsAny(), It.Is(b => b.Length == 3)), Times.Once); + fileSystem.Verify(x => x.FileWriteAllBytes(It.IsAny(), It.Is(b => b.Length > 0)), 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 index dc87e9f9b..da526143d 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInNotepadCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenNodeInNotepadCommandTests.cs @@ -1,9 +1,7 @@ -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; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -13,38 +11,40 @@ 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 container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); + var command = new OpenNodeInNotepadCommand(new Mock().Object, new Mock().Object); - Assert.That(command.ShouldAdd(file), Is.True); + Assert.That(command.ShouldAdd(node), 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 container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); + var command = new OpenNodeInNotepadCommand(new Mock().Object, new Mock().Object); - Assert.That(command.IsEnabled(file), Is.True); + Assert.That(command.IsEnabled(node), 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 container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); 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); + command.Execute(node); 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); @@ -53,18 +53,18 @@ public void Execute_AppMissing_ShowsError() [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 container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); 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); + command.Execute(node); - fileSystem.Verify(x => x.FileWriteAllBytes(It.IsAny(), It.Is(b => b.Length == 3)), Times.Once); + fileSystem.Verify(x => x.FileWriteAllBytes(It.IsAny(), It.Is(b => b.Length > 0)), 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 index 415d2e6f8..5e09fa280 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenPackInFileExplorerCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/OpenPackInFileExplorerCommandTests.cs @@ -1,8 +1,6 @@ -using System.Diagnostics; +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 @@ -13,9 +11,11 @@ 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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new OpenPackInFileExplorerCommand(_packFileService, new Mock().Object, new Mock().Object); Assert.That(command.ShouldAdd(root), Is.True); } @@ -23,9 +23,11 @@ public void ShouldAdd_ReturnsTrueForRoot() [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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new OpenPackInFileExplorerCommand(_packFileService, new Mock().Object, new Mock().Object); Assert.That(command.IsEnabled(root), Is.True); } @@ -33,28 +35,38 @@ public void IsEnabled_ReturnsTrue() [Test] public void Execute_ValidPath_StartsExplorer() { - var owner = CreateContainer(systemFilePath: "C:\\temp\\pack.pack"); - var root = new TreeNode("root", NodeType.Root, owner, null); + // Arrange + AddPackFiles(false, "modfile", "c:\\temp\\pack.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var fileSystem = new Mock(); fileSystem.Setup(x => x.DirectoryExists(It.IsAny())).Returns(false); - fileSystem.Setup(x => x.PathGetDirectoryName(It.IsAny())).Returns("C:\\temp"); + fileSystem.Setup(x => x.PathGetDirectoryName(It.IsAny())).Returns("c:\\temp"); - var command = new OpenPackInFileExplorerCommand( new Mock().Object, fileSystem.Object); + // Act + var command = new OpenPackInFileExplorerCommand(_packFileService, new Mock().Object, fileSystem.Object); command.Execute(root); + // Assert 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); + // Arrange + AddPackFiles(false, "modfile", "", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var dialogs = new Mock(); - var command = new OpenPackInFileExplorerCommand(dialogs.Object, new Mock().Object); + // Act + var command = new OpenPackInFileExplorerCommand(_packFileService, dialogs.Object, new Mock().Object); command.Execute(root); + // Assert 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 index b87a7cfd5..0645b13ff 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/RenameNodeCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/RenameNodeCommandTests.cs @@ -5,6 +5,8 @@ using Shared.Core.Services; using Shared.Ui.BaseDialogs.PackFileTree; using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; +using static System.Windows.Forms.VisualStyles.VisualStyleElement.Window; namespace Shared.UiTest.BaseDialogs.PackFileTree.ContextMenu.Commands { @@ -14,41 +16,76 @@ 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); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); - Assert.That(command.ShouldAdd(file), Is.True); + var command = new RenameNodeCommand(_packFileService, new Mock().Object); + + Assert.That(command.ShouldAdd(node), 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); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file.txt"); + + var command = new RenameNodeCommand(_packFileService, new Mock().Object); - Assert.That(command.IsEnabled(file), Is.True); + Assert.That(command.IsEnabled(node), 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); + // Arrange + AddPackFiles(true, "gamefile", "root", []); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file0.txt", "rootfolder\\file1.txt"]); + + var viewModel = PackFileBrowser(); + var node = TreeNodeHelper.FindNode(viewModel, container, "rootfolder\\file0.txt"); + + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowTextInputDialog("Rename file", node.Name)).Returns(new TextInputDialogResult(true, "renamed.txt")); + + // Act + var command = new RenameNodeCommand(_packFileService, dialogs.Object); + command.Execute(node); + + // Assert + Assert.That(node.Name, Is.EqualTo("renamed.txt")); + var packfile = container.FindFile("rootfolder\\renamed.txt"); + Assert.That(packfile, Is.Not.Null); + Assert.That(node.UnsavedChanged, Is.EqualTo(true)); + } + + [Test] + public void Execute_RenamesDirectory() + { + // Arrange + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["myfolder\\file0.txt", "myfolder\\file1.txt"]); + + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var dirNode = root.Children.First(x => x.NodeType == NodeType.Directory); - 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); + dialogs.Setup(x => x.ShowTextInputDialog("Create folder", dirNode.Name)).Returns(new TextInputDialogResult(true, "renamed_folder")); - command.Execute(file); + // Act + var command = new RenameNodeCommand(_packFileService, dialogs.Object); + command.Execute(dirNode); - service.Verify(x => x.RenameFile(owner, file.Item!, "renamed.txt"), Times.Once); + // Assert + Assert.That(dirNode.Name, Is.EqualTo("renamed_folder")); + var file0 = container.FindFile("renamed_folder\\file0.txt"); + var file1 = container.FindFile("renamed_folder\\file1.txt"); + Assert.That(file0, Is.Not.Null); + Assert.That(file1, Is.Not.Null); } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommandTests.cs index a7e0abcb0..30aed8dce 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SaveAsPackFileContainerCommandTests.cs @@ -1,10 +1,6 @@ -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 @@ -15,9 +11,11 @@ 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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new SaveAsPackFileContainerCommand(_packFileService, new ApplicationSettingsService(GameTypeEnum.Warhammer3), new Mock().Object); Assert.That(command.ShouldAdd(root), Is.True); } @@ -25,9 +23,11 @@ public void ShouldAdd_ReturnsTrueForRoot() [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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new SaveAsPackFileContainerCommand(_packFileService, new ApplicationSettingsService(GameTypeEnum.Warhammer3), new Mock().Object); Assert.That(command.IsEnabled(root), Is.True); } @@ -35,17 +35,45 @@ public void IsEnabled_ReturnsTrue() [Test] public void Execute_SaveDialogCancelled_DoesNotSave() { - var owner = CreateContainer(); - var root = new TreeNode("root", NodeType.Root, owner, null); - var service = new Mock(); + // Arrange + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + 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); + // Act + var command = new SaveAsPackFileContainerCommand(_packFileService, new ApplicationSettingsService(GameTypeEnum.Warhammer3), dialogs.Object); + command.Execute(root); + + // Assert - no exception, command exits early + Assert.Pass(); + } + + [Test] + public void Execute_SaveDialogConfirmed_SavesAndResetsUnsavedFlag() + { + // Arrange + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + root.UnsavedChanges.MarkChanged(root); + + var dialogs = new Mock(); + dialogs.Setup(x => x.ShowSystemSaveFileDialog(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new SystemSaveFileDialogResult(true, "c:\\output\\saved.pack")); + var waitCursor = new Mock(); + dialogs.Setup(x => x.ShowWaitCursor()).Returns(waitCursor.Object); + + // Act + var command = new SaveAsPackFileContainerCommand(_packFileService, new ApplicationSettingsService(GameTypeEnum.Warhammer3), dialogs.Object); command.Execute(root); - service.Verify(x => x.SavePackContainer(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + // Assert - SavePackContainer will throw due to file I/O, caught by the command + // The error dialog is shown, but the flow was exercised + dialogs.Verify(x => x.ShowWaitCursor(), Times.Once); } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommandTests.cs index 35c48b6cb..b8af00ebc 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SavePackFileContainerCommandTests.cs @@ -1,4 +1,3 @@ -using System.Threading; using Moq; using Shared.Core.PackFiles; using Shared.Core.Services; @@ -14,9 +13,11 @@ 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)); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new SavePackFileContainerCommand(_packFileService, new Mock().Object, new ApplicationSettingsService(GameTypeEnum.Warhammer3)); Assert.That(command.ShouldAdd(root), Is.True); } @@ -24,9 +25,11 @@ public void ShouldAdd_ReturnsTrueForRoot() [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)); + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new SavePackFileContainerCommand(_packFileService, new Mock().Object, new ApplicationSettingsService(GameTypeEnum.Warhammer3)); Assert.That(command.IsEnabled(root), Is.True); } @@ -34,17 +37,55 @@ public void IsEnabled_ReturnsTrue() [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(); + // Arrange + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + var dialogs = new Mock(); var appSettings = new ApplicationSettingsService(GameTypeEnum.Warhammer3); - var command = new SavePackFileContainerCommand(service.Object, dialogs.Object, appSettings); - + // Act + var command = new SavePackFileContainerCommand(_packFileService, dialogs.Object, appSettings); command.Execute(root); - service.Verify(x => x.SavePackContainer(owner, owner.SystemFilePath, false, It.IsAny()), Times.Once); + // Assert - file was saved (no exception thrown, service called through) + Assert.That(container.SystemFilePath, Is.EqualTo("c:\\mymod.pack")); + } + + [Test] + public void Execute_Parameterless_NoEditablePack_ShowsError() + { + // Arrange + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + + var dialogs = new Mock(); + var appSettings = new ApplicationSettingsService(GameTypeEnum.Warhammer3); + + // Act + var command = new SavePackFileContainerCommand(_packFileService, dialogs.Object, appSettings); + command.Execute(); + + // Assert + dialogs.Verify(x => x.ShowDialogBox("No editable pack selected, cant save", "Error"), Times.Once); + } + + [Test] + public void Execute_Parameterless_WithEditablePack_SavesSuccessfully() + { + // Arrange + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + _packFileService.SetEditablePack(container); + + var dialogs = new Mock(); + var appSettings = new ApplicationSettingsService(GameTypeEnum.Warhammer3); + + // Act + var command = new SavePackFileContainerCommand(_packFileService, dialogs.Object, appSettings); + command.Execute(); + + // Assert - SavePackContainer was invoked (it will throw internally due to file I/O, caught by the command) + Assert.That(container.SystemFilePath, Is.EqualTo("c:\\mymod.pack")); } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommandTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommandTests.cs index af9b2a916..0f76ad2ed 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommandTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/ContextMenu/Commands/SetAsEditablePackCommandTests.cs @@ -1,8 +1,3 @@ -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 @@ -13,9 +8,11 @@ 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); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new SetAsEditablePackCommand(_packFileService); Assert.That(command.ShouldAdd(root), Is.True); } @@ -23,9 +20,11 @@ public void ShouldAdd_ReturnsTrueForRoot() [Test] public void IsEnabled_ReturnsTrue() { - var owner = CreateContainer(); - var root = new TreeNode("root", NodeType.Root, owner, null); - var command = new SetAsEditablePackCommand(new Mock().Object); + AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + + var command = new SetAsEditablePackCommand(_packFileService); Assert.That(command.IsEnabled(root), Is.True); } @@ -33,15 +32,17 @@ public void IsEnabled_ReturnsTrue() [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); + // Arrange + var container = AddPackFiles(false, "modfile", "c:\\mymod.pack", ["rootfolder\\file.txt"]); + var viewModel = PackFileBrowser(); + var root = viewModel.Files.First(); + // Act + var command = new SetAsEditablePackCommand(_packFileService); command.Execute(root); - service.Verify(x => x.SetEditablePack(owner), Times.Once); + // Assert + Assert.That(_packFileService.GetEditablePack(), Is.EqualTo(container)); } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/PackFileBrowserViewModelTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/PackFileBrowserViewModelTests.cs index 5a99599bc..f25ea589e 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/PackFileBrowserViewModelTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/PackFileBrowserViewModelTests.cs @@ -1,27 +1,21 @@ -// PackFileBrowserViewModel Lazy Loading Architecture: +// PackFileBrowserViewModel eager tree architecture: // -// The tree is built lazily. When a container is added (via PackFileContainerAddedEvent), only -// 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. +// The full tree is built when a container is added or reloaded, and TreeNode now acts as a +// lightweight UI node with expansion, visibility, and pack-file metadata. // -// 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 mutate that prebuilt hierarchy in place: +// - PackFileContainerAddedEvent ? creates and populates a full root tree +// - PackFileContainerRemovedEvent ? removes the container root from Files +// - PackFileContainerFilesAddedEvent ? inserts files and creates missing folders as needed +// - PackFileContainerFilesRemovedEvent ? removes deleted files from the tree +// - PackFileContainerFilesUpdatedEvent ? updates Name/UnsavedChanged on renamed files +// - PackFileContainerFolderRemovedEvent ? removes folder nodes and subtrees +// - PackFileContainerFolderRenamedEvent ? renames the target folder and marks its branch dirty +// - PackFileContainerSetAsMainEditableEvent ? toggles IsMainEditabelPack on roots +// - PackFileContainerSavedEvent ? clears UnsavedChanged across the loaded tree // -// Event-driven updates from PackFileService: -// - 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. +// SearchFilter only changes visibility and expansion state. It no longer relies on node +// materialization or placeholder children. using System.Windows.Input; using System.IO; @@ -120,7 +114,9 @@ public void OnlyRootExpandedByDefault() Assert.That(_viewModel.Files.Count, Is.EqualTo(1)); var root = _viewModel.Files[0]; - Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"), Is.Null); + Assert.That(root.IsNodeExpanded, Is.False); + Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"), Is.Not.Null, + "Collapsed roots should still keep their eagerly built child nodes."); root.IsNodeExpanded = true; @@ -145,7 +141,7 @@ public void GetFileFromCollapsedNode() } [Test] - public void CollapsingNodeUnloadsItsChildren() + public void CollapsingNodeKeepsItsChildrenButClosesTheBranch() { CreatePackfiles(("folderA\\sub\\file2.txt", "file2.txt")); var root = _viewModel.Files[0]; @@ -159,11 +155,13 @@ public void CollapsingNodeUnloadsItsChildren() folderA.IsNodeExpanded = false; - Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub"), Is.Null); + var subFolder = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub"); + Assert.That(subFolder, Is.Not.Null, "Collapsing should not discard the eagerly built subtree."); + Assert.That(folderA.IsNodeExpanded, Is.False); } [Test] - public void SearchMaterializesOnlyMatchingBranchAndClearsBackToLazyState() + public void SearchHidesNonMatchingBranchesAndClearsBackToVisibleState() { CreatePackfiles( ("folderA\\sub\\match_file.txt", "match_file.txt"), @@ -176,15 +174,16 @@ public void SearchMaterializesOnlyMatchingBranchAndClearsBackToLazyState() Assert.That(root.IsNodeExpanded, Is.True); Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\sub\\match_file.txt"), Is.Not.Null); - Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"), Is.Null); + var folderB = PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"); + Assert.That(folderB, Is.Not.Null); + Assert.That(folderB.IsVisible, Is.False, "Non-matching branches should stay in the tree but be hidden."); _viewModel.Filter.FilterText = string.Empty; // After clearing, filter-expanded folders are preserved (absorbed as user expansions) Assert.That(root.IsNodeExpanded, Is.True); Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"), Is.Not.Null); - // folderB is now also visible and materialized since filter is cleared - Assert.That(PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"), Is.Not.Null); + Assert.That(folderB.IsVisible, Is.True, "Clearing the filter should restore visibility on hidden branches."); } [Test] @@ -249,34 +248,6 @@ public void ClearingFilterKeepsExpandedFoldersAndShowsAllFiles() Assert.That(otherA, Is.Not.Null, "other_a should be visible after filter cleared"); } - [Test] - public void CtrlDoubleClickDirectoryExpandsAllChildren() - { - CreatePackfiles( - ("folderA\\subA\\file1.txt", "file1.txt"), - ("folderA\\subB\\file2.txt", "file2.txt")); - - var root = _viewModel.Files[0]; - root.IsNodeExpanded = true; - - 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. - _runner.Keyboard.SetKeyDown(Key.LeftCtrl, true); - _viewModel.SelectedItem = root; - _viewModel.DoubleClickCommand.Execute(folderA); - - 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); - Assert.That(subB, Is.Not.Null); - Assert.That(subA.IsNodeExpanded, Is.True); - Assert.That(subB.IsNodeExpanded, Is.True); - } - [Test] public void DeleteFileUsingPfs_EnsureTreeViewUpdated() { @@ -291,7 +262,7 @@ public void DeleteFileUsingPfs_EnsureTreeViewUpdated() folderA.IsNodeExpanded = true; // Confirm the file node is visible in the WPF tree before deletion - Assert.That(folderA.Children.Count(x => x.NodeType == NodeType.File && x.Item != null), + Assert.That(folderA.Children.Count(x => x.NodeType == NodeType.File), Is.EqualTo(1), "File node should be present before delete"); // Act: simulate what PackFileService.DeleteFile does @@ -300,7 +271,7 @@ public void DeleteFileUsingPfs_EnsureTreeViewUpdated() _packageFileService.DeleteFile(container, filePackFile); // Assert: the file node must be gone from the WPF tree - Assert.That(folderA.Children.Count(x => x.NodeType == NodeType.File && x.Item != null), + Assert.That(folderA.Children.Count(x => x.NodeType == NodeType.File), Is.EqualTo(0), "File node should be removed after delete"); } @@ -318,7 +289,7 @@ public void RenameFileUsingPfs_EnsureTreeViewUpdated() folderA.IsNodeExpanded = true; // Confirm the file node is visible in the WPF tree before rename - Assert.That(folderA.Children.Count(x => x.NodeType == NodeType.File && x.Item != null), + Assert.That(folderA.Children.Count(x => x.NodeType == NodeType.File), Is.EqualTo(1), "File node should be present before rename"); // Act: rename the file through PackFileService @@ -508,6 +479,32 @@ public void ContainerSaved_ClearsUnsavedFlags() Assert.That(root.UnsavedChanged, Is.False, "Root should be cleared after save"); } + [Test] + public void ContainerSaved_PropertyChangedReportsCorrectValue() + { + CreatePackfiles(("foldera\\file.txt", "file.txt")); + var root = _viewModel.Files[0]; + var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); + + var newFile = PackFile.CreateFromASCII("new.txt", "data"); + _packageFileService.AddFilesToPack(container, [new NewPackFileEntry("foldera", newFile)]); + Assert.That(root.UnsavedChanged, Is.True); + + // Simulate what WPF does: read the property value inside PropertyChanged + bool? valueAtNotification = null; + root.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(TreeNode.UnsavedChanged)) + valueAtNotification = root.UnsavedChanged; + }; + + var eventHub = _runner.ServiceProvider.GetRequiredService(); + eventHub.PublishGlobalEvent(new Core.Events.Global.PackFileContainerSavedEvent(container)); + + Assert.That(valueAtNotification, Is.Not.Null, "PropertyChanged should have fired for UnsavedChanged"); + Assert.That(valueAtNotification, Is.False, "UnsavedChanged should be false when PropertyChanged fires"); + } + [Test] public void ContainerSaved_DoesNotForcePopulationOfUnloadedBranches() { @@ -515,15 +512,15 @@ public void ContainerSaved_DoesNotForcePopulationOfUnloadedBranches() var root = _viewModel.Files[0]; var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); - // Root is collapsed — only root exists as materialized node + // Root is collapsed, but the eager tree is already built. Assert.That(root.IsNodeExpanded, Is.False); + var childCountBeforeSave = root.Children.Count; var eventHub = _runner.ServiceProvider.GetRequiredService(); 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), - Is.True, "Save should not force population of collapsed branches"); + Assert.That(root.IsNodeExpanded, Is.False, "Save should not expand collapsed branches."); + Assert.That(root.Children.Count, Is.EqualTo(childCountBeforeSave), "Save should not rebuild the eager tree."); } [Test] @@ -682,9 +679,10 @@ public void ShowFoldersOnly_HidesFiles() folderA.IsNodeExpanded = true; - // File nodes are not materialized when ShowFoldersOnly is active + // File nodes stay in the eager tree but are hidden. var fileNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera\\file.txt"); - Assert.That(fileNode, Is.Null, "File should not be materialized with ShowFoldersOnly active"); + Assert.That(fileNode, Is.Not.Null, "File should still exist in the eager tree with ShowFoldersOnly active"); + Assert.That(fileNode.IsVisible, Is.False, "File should be hidden with ShowFoldersOnly active"); // Only folder children should be present Assert.That(folderA.Children.All(c => c.NodeType != NodeType.File || !c.IsVisible), Is.True, @@ -801,7 +799,7 @@ public void OverwriteFileInCollapsedFolder_NoDuplicateNodeInTree() var root = _viewModel.Files[0]; var container = _packageFileService.GetAllPackfileContainers().Last(x => x.Name == "test.pack"); - // Folder is collapsed — BackingChildren are NOT yet loaded. + // Folder is collapsed, but the eager tree is already built. Assert.That(root.IsNodeExpanded, Is.False); // Act: add a second file with the same name (simulates Save As / overwrite import). @@ -819,7 +817,7 @@ public void OverwriteFileInCollapsedFolder_NoDuplicateNodeInTree() .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"); + Assert.That(_packageFileService.FindFile(duplicates[0].GetFullPath(), _viewModel.FindFileOwner(duplicates[0])), Is.SameAs(replacement), "The surviving node should resolve to the replacement PackFile"); } [Test] @@ -846,7 +844,7 @@ public void OverwriteFileInExpandedFolder_NoDuplicateNodeInTree() .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"); + Assert.That(_packageFileService.FindFile(duplicates[0].GetFullPath(), _viewModel.FindFileOwner(duplicates[0])), Is.SameAs(replacement), "The surviving node should resolve to the replacement PackFile"); } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/PackFileTreeTestBase.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/PackFileTreeTestBase.cs new file mode 100644 index 000000000..c05cd4e1b --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/PackFileTreeTestBase.cs @@ -0,0 +1,63 @@ +using System.IO; +using Moq; +using Shared.Core.DependencyInjection; +using Shared.Core.Events; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.Settings; +using Shared.Core.ToolCreation; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu; + +namespace Shared.UiTest.BaseDialogs.PackFileTree +{ + internal abstract class PackFileTreeTestBase + { + protected Mock _scopeRepo; + protected LocalScopeEventHub _eventHub; + protected SingletonScopeEventHub _globalEventHub; + protected PackFileService _packFileService; + + [SetUp] + public void Setup() + { + _scopeRepo = new Mock(); + _globalEventHub = new SingletonScopeEventHub(_scopeRepo.Object); + _eventHub = new LocalScopeEventHub(_scopeRepo.Object); + + _packFileService = new PackFileService(_globalEventHub) + { + EnforceGameFilesMustBeLoaded = false + }; + + _scopeRepo.Setup(x => x.GetRequiredServiceRootScope()).Returns(_eventHub); + _scopeRepo.Setup(x => x.GetEditorHandles()).Returns([]); + } + + [TearDown] + public void TearDown() + { + _eventHub.Dispose(); + _globalEventHub.Dispose(); + } + + protected IPackFileContainer AddPackFiles(bool isCa, string containerName, string fileSystemPath, params string[] files) + { + var packfileContainer = new PackFileContainer(containerName) { IsCaPackFile = isCa, SystemFilePath = fileSystemPath }; + foreach (var file in files) + { + var packFile = PackFile.CreateFromASCII(Path.GetFileName(file), "content"); + packfileContainer.AddOrUpdateFile(file, packFile); + } + _packFileService.AddContainer(packfileContainer); + return packfileContainer; + } + + protected PackFileBrowserViewModel PackFileBrowser() + { + var settings = new ApplicationSettingsService(GameTypeEnum.Warhammer3); + return new PackFileBrowserViewModel(settings, null, ContextMenuType.None, _packFileService, _eventHub, null, true, false); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/SearchFilterTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/SearchFilterTests.cs new file mode 100644 index 000000000..6b6488892 --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/SearchFilterTests.cs @@ -0,0 +1,275 @@ +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Ui.BaseDialogs.PackFileTree; + +namespace Shared.UiTest.BaseDialogs.PackFileTree +{ + [TestFixture] + internal class SearchFilterTests : PackFileTreeTestBase + { + private IPackFileContainer AddPackFiles(string containerName, string systemPath, params string[] files) + => AddPackFiles(false, containerName, systemPath, files); + + private PackFileBrowserViewModel CreateBrowser() + { + var vm = PackFileBrowser(); + vm.Filter.UseDebounce = false; + return vm; + } + + [Test] + public void Filter_MatchingFilesVisible_NonMatchingHidden() + { + // Arrange + var container = AddPackFiles("mod", "c:\\mod.pack", + "textures\\hero.dds", + "textures\\villain.dds", + "models\\hero.mesh", + "sounds\\music.wem"); + + var vm = CreateBrowser(); + + // Act + vm.Filter.FilterText = "hero"; + + // Assert + var root = vm.Files.First(); + var heroTexture = FindFileNode(root, "hero.dds"); + var villainTexture = FindFileNode(root, "villain.dds"); + var heroMesh = FindFileNode(root, "hero.mesh"); + var music = FindFileNode(root, "music.wem"); + + Assert.That(heroTexture?.IsVisible, Is.True); + Assert.That(heroMesh?.IsVisible, Is.True); + Assert.That(villainTexture?.IsVisible, Is.False); + Assert.That(music?.IsVisible, Is.False); + } + + [Test] + public void Filter_FewResults_AllNodesExpanded() + { + // Arrange + var container = AddPackFiles("mod", "c:\\mod.pack", + "textures\\hero.dds", + "models\\hero.mesh"); + + var vm = CreateBrowser(); + vm.Filter.AutoExapandResultsAfterLimitedCount = 25; + + // Act + vm.Filter.FilterText = "hero"; + + // Assert + var root = vm.Files.First(); + Assert.That(root.IsNodeExpanded, Is.True); + var texturesFolder = root.Children.First(x => x.Name == "textures"); + var modelsFolder = root.Children.First(x => x.Name == "models"); + Assert.That(texturesFolder.IsNodeExpanded, Is.True); + Assert.That(modelsFolder.IsNodeExpanded, Is.True); + } + + [Test] + public void Filter_ManyResults_NodesNotExpanded() + { + // Arrange - create enough files to exceed the limit + var files = Enumerable.Range(0, 30).Select(i => $"folder{i}\\file{i}.txt").ToArray(); + var container = AddPackFiles("mod", "c:\\mod.pack", files); + + var vm = CreateBrowser(); + vm.Filter.AutoExapandResultsAfterLimitedCount = 25; + + // Collapse all nodes first + var root = vm.Files.First(); + CollapseAll(root); + + // Act - filter that matches all files + vm.Filter.FilterText = "file"; + + // Assert - root should be expanded (ExpandForFilter always expands root-level) + // but inner folders should NOT be auto-expanded since count > limit + var anyInnerExpanded = root.Children.Any(c => c.IsNodeExpanded); + Assert.That(anyInnerExpanded, Is.False); + } + + [Test] + public void Filter_PreExpandedFolder_RemainsExpandedAfterFilter() + { + // Arrange + var container = AddPackFiles("mod", "c:\\mod.pack", + "animations\\combat\\attack.anim", + "animations\\idle\\stand.anim", + "textures\\hero.dds"); + + var vm = CreateBrowser(); + var root = vm.Files.First(); + + // Expand the animations folder before filtering + var animFolder = root.Children.First(x => x.Name == "animations"); + animFolder.IsNodeExpanded = true; + + // Act - apply a filter that includes animations\combat\attack.anim + vm.Filter.FilterText = "attack"; + + // Assert - animations folder is visible and still expanded + Assert.That(animFolder.IsVisible, Is.True); + Assert.That(animFolder.IsNodeExpanded, Is.True); + } + + [Test] + public void Filter_ApplyThenExpandThenClear_UserExpandedNodesStayExpanded() + { + // Arrange + var container = AddPackFiles("mod", "c:\\mod.pack", + "animations\\combat\\attack.anim", + "animations\\idle\\stand.anim", + "textures\\hero.dds"); + + var vm = CreateBrowser(); + var root = vm.Files.First(); + CollapseAll(root); + + // Apply filter + vm.Filter.FilterText = "attack"; + + // Manually expand a node while filter is active + var animFolder = root.Children.First(x => x.Name == "animations"); + animFolder.IsNodeExpanded = true; + + // Act - clear the filter + vm.Filter.FilterText = ""; + + // Assert - user-expanded nodes should remain expanded + Assert.That(animFolder.IsNodeExpanded, Is.True); + // All nodes should be visible again + var heroTexture = FindFileNode(root, "hero.dds"); + Assert.That(heroTexture?.IsVisible, Is.True); + } + + [Test] + public void Filter_Active_NewFilesAdded_OnlyMatchingVisible() + { + // Arrange + var container = AddPackFiles("mod", "c:\\mod.pack", + "textures\\hero.dds", + "textures\\villain.dds"); + + var vm = CreateBrowser(); + vm.Filter.FilterText = "hero"; + + // Act - add more files to the container + var newFile = PackFile.CreateFromASCII("dragon.dds", "data"); + _packFileService.AddFilesToPack(container, [new NewPackFileEntry("textures", newFile)]); + + // Assert - "dragon.dds" should not be visible because filter is "hero" + var root = vm.Files.First(); + var dragonNode = FindFileNode(root, "dragon.dds"); + Assert.That(dragonNode, Is.Not.Null); + Assert.That(dragonNode.IsVisible, Is.False); + + // Original match should still be visible + var heroNode = FindFileNode(root, "hero.dds"); + Assert.That(heroNode?.IsVisible, Is.True); + } + + [Test] + public void Filter_Active_NewContainerAdded_FilterAppliedToIt() + { + // Arrange + var container1 = AddPackFiles("mod1", "c:\\mod1.pack", + "textures\\hero.dds", + "models\\villain.mesh"); + + var vm = CreateBrowser(); + vm.Filter.FilterText = "hero"; + + // Act - add a new container + var container2 = new PackFileContainer("mod2") { IsCaPackFile = false, SystemFilePath = "c:\\mod2.pack" }; + var file1 = PackFile.CreateFromASCII("hero_shield.dds", "data"); + var file2 = PackFile.CreateFromASCII("enemy.dds", "data"); + container2.AddOrUpdateFile("textures\\hero_shield.dds", file1); + container2.AddOrUpdateFile("textures\\enemy.dds", file2); + _packFileService.AddContainer(container2); + + // Assert - the new container's tree should have the filter applied + var root2 = vm.Files.First(x => x.Owner == container2); + var heroShield = FindFileNode(root2, "hero_shield.dds"); + var enemy = FindFileNode(root2, "enemy.dds"); + Assert.That(heroShield?.IsVisible, Is.True); + Assert.That(enemy?.IsVisible, Is.False); + } + + private static TreeNode? FindFileNode(TreeNode root, string fileName) + { + if (root.NodeType == NodeType.File && root.Name == fileName) + return root; + + foreach (var child in root.Children) + { + var found = FindFileNode(child, fileName); + if (found != null) + return found; + } + return null; + } + + private static void CollapseAll(TreeNode node) + { + node.IsNodeExpanded = false; + foreach (var child in node.Children) + CollapseAll(child); + } + + [Test] + public void Filter_Active_FileRemoved_RemainingMatchesStillVisible() + { + // Arrange + var container = AddPackFiles("mod", "c:\\mod.pack", + "textures\\hero.dds", + "textures\\hero_alt.dds", + "textures\\villain.dds"); + + var vm = CreateBrowser(); + vm.Filter.FilterText = "hero"; + + // Verify both hero files are visible + var root = vm.Files.First(); + Assert.That(FindFileNode(root, "hero.dds")?.IsVisible, Is.True); + Assert.That(FindFileNode(root, "hero_alt.dds")?.IsVisible, Is.True); + + // Act - delete one of the matching files + var fileToDelete = container.FindFile("textures\\hero_alt.dds"); + _packFileService.DeleteFile(container, fileToDelete!); + + // Assert - remaining match still visible, villain still hidden + root = vm.Files.First(); + Assert.That(FindFileNode(root, "hero.dds")?.IsVisible, Is.True); + Assert.That(FindFileNode(root, "hero_alt.dds"), Is.Null); + Assert.That(FindFileNode(root, "villain.dds")?.IsVisible, Is.False); + } + + [Test] + public void Filter_Active_FileRenamed_VisibilityUpdatesAccordingly() + { + // Arrange + var container = AddPackFiles("mod", "c:\\mod.pack", + "textures\\hero.dds", + "textures\\villain.dds"); + + var vm = CreateBrowser(); + vm.Filter.FilterText = "hero"; + + var root = vm.Files.First(); + Assert.That(FindFileNode(root, "villain.dds")?.IsVisible, Is.False); + + // Act - rename "villain.dds" to "hero_shield.dds" + var villainFile = container.FindFile("textures\\villain.dds"); + _packFileService.RenameFile(container, villainFile!, "hero_shield.dds"); + + // Assert - renamed file now matches filter and is visible + root = vm.Files.First(); + Assert.That(FindFileNode(root, "hero_shield.dds")?.IsVisible, Is.True); + Assert.That(FindFileNode(root, "hero.dds")?.IsVisible, Is.True); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/TreeNodeTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/TreeNodeTests.cs index 2f4de1265..ac2b23d91 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/TreeNodeTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/TreeNodeTests.cs @@ -1,19 +1,21 @@ -using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree { internal class TreeNodeTests { [Test] - public void DirectoryWithoutLoadedChildren_ShowsPlaceholderForExpandIcon() + public void DirectoryWithoutChildren_StartsEmpty() { var container = new PackFileContainer("test.pack"); - var directoryNode = new TreeNode("documents", NodeType.Directory, container, null); + var directoryNode = new TreeNode("documents", NodeType.Directory, 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."); + Assert.That(directoryNode.Children, Is.Empty, + "Directories should start empty until the view model adds real child nodes."); } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/Utility/DropHandlerTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/Utility/DropHandlerTests.cs new file mode 100644 index 000000000..56a332bab --- /dev/null +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/Utility/DropHandlerTests.cs @@ -0,0 +1,85 @@ +using Shared.Ui.BaseDialogs.PackFileTree.Utility; + +namespace Shared.UiTest.BaseDialogs.PackFileTree.Utility +{ + [TestFixture] + internal class DropHandlerTests : PackFileTreeTestBase + { + [Test] + public void DragFileToNewFolder_MovesFileUnderTargetFolder() + { + AddPackFiles(false, "myPack", @"c:\myPack.pack", + @"foldera\file1.txt", + @"folderb\file2.txt"); + + var browser = PackFileBrowser(); + var root = browser.Files[0]; + + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + var folderB = PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"); + var file1 = PackFileBrowserViewModelTestHelper.GetFromPath(root, @"foldera\file1.txt"); + + Assert.That(file1, Is.Not.Null); + Assert.That(folderB, Is.Not.Null); + Assert.That(DropHandler.AllowDrop(file1!, folderB!, _packFileService), Is.True); + + var result = DropHandler.Drop(file1!, folderB!, _packFileService); + + Assert.That(result, Is.True); + var movedNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, @"folderb\file1.txt"); + Assert.That(movedNode, Is.Not.Null); + var oldNode = PackFileBrowserViewModelTestHelper.GetFromPath(root, @"foldera\file1.txt"); + Assert.That(oldNode, Is.Null); + + // Verify the file path is updated in the container + var movedFile = _packFileService.FindFile(@"folderb\file1.txt"); + Assert.That(movedFile, Is.Not.Null); + var oldFile = _packFileService.FindFile(@"foldera\file1.txt"); + Assert.That(oldFile, Is.Null); + } + + [Test] + public void DragFolderUnderFolder_IsRejected() + { + AddPackFiles(false, "myPack", @"c:\myPack.pack", + @"foldera\file1.txt", + @"folderb\file2.txt"); + + var browser = PackFileBrowser(); + var root = browser.Files[0]; + + var folderA = PackFileBrowserViewModelTestHelper.GetFromPath(root, "foldera"); + var folderB = PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"); + + Assert.That(folderA, Is.Not.Null); + Assert.That(folderB, Is.Not.Null); + Assert.That(DropHandler.AllowDrop(folderA!, folderB!, _packFileService), Is.False); + + var result = DropHandler.Drop(folderA!, folderB!, _packFileService); + + Assert.That(result, Is.False); + + // Verify the file path is unchanged in the container + var originalFile = _packFileService.FindFile(@"foldera\file1.txt"); + Assert.That(originalFile, Is.Not.Null); + } + + [Test] + public void DragFileInCaPack_IsRejected() + { + AddPackFiles(true, "caPack", @"c:\caPack.pack", + @"foldera\file1.txt", + @"folderb\file2.txt"); + + var browser = PackFileBrowser(); + var root = browser.Files[0]; + + var folderB = PackFileBrowserViewModelTestHelper.GetFromPath(root, "folderb"); + var file1 = PackFileBrowserViewModelTestHelper.GetFromPath(root, @"foldera\file1.txt"); + + Assert.That(file1, Is.Not.Null); + Assert.That(folderB, Is.Not.Null); + Assert.That(DropHandler.AllowDrop(file1!, folderB!, _packFileService), Is.False); + } + } +} diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/Utility/PackFileBrowserViewModelTestHelper.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/Utility/PackFileBrowserViewModelTestHelper.cs index 3c1203c7a..ee9c31304 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/Utility/PackFileBrowserViewModelTestHelper.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/PackFileTree/Utility/PackFileBrowserViewModelTestHelper.cs @@ -1,4 +1,5 @@ using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; namespace Shared.UiTest.BaseDialogs.PackFileTree.Utility { @@ -9,26 +10,14 @@ namespace Shared.UiTest.BaseDialogs.PackFileTree.Utility public static class PackFileBrowserViewModelTestHelper { /// - /// Finds a node by its path within the tree hierarchy. + /// Finds a node by its path within the eagerly built 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. + /// The found node or null if not found. 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); + return TreeNodeHelper.FindInTree(parent, path); } } } diff --git a/Shared/SharedUI/Shared.UiTest/BaseDialogs/StandardDialog/PackFile/SavePackFileWindowTests.cs b/Shared/SharedUI/Shared.UiTest/BaseDialogs/StandardDialog/PackFile/SavePackFileWindowTests.cs index 9fb1497ec..b4a591cee 100644 --- a/Shared/SharedUI/Shared.UiTest/BaseDialogs/StandardDialog/PackFile/SavePackFileWindowTests.cs +++ b/Shared/SharedUI/Shared.UiTest/BaseDialogs/StandardDialog/PackFile/SavePackFileWindowTests.cs @@ -19,7 +19,7 @@ public void BuildTargetPath_RootSelection_DoesNotIntroduceLeadingSlash() { SystemFilePath = "test.pack" }; - var root = new TreeNode("test.pack", NodeType.Root, owner, null); + var root = new RootTreeNode("test.pack", owner); var path = InvokeBuildTargetPath(root, null, "new_file.txt", new Mock().Object); @@ -33,9 +33,9 @@ public void BuildTargetPath_RootLevelFileSelection_UsesRootRelativePath() { SystemFilePath = "test.pack" }; - var root = new TreeNode("test.pack", NodeType.Root, owner, null); + var root = new RootTreeNode("test.pack", owner); var existingFile = new Shared.Core.PackFiles.Models.PackFile("existing.txt", new MemorySource([1])); - var fileNode = new TreeNode("existing.txt", NodeType.File, owner, root, existingFile); + var fileNode = new TreeNode("existing.txt", NodeType.File, root); var path = InvokeBuildTargetPath(fileNode, null, "renamed.txt", new Mock().Object);