From 1b63c922dabc7456bc97273ab57415781ae7bafe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20L=C3=BCbe=C3=9F?= Date: Thu, 10 Jul 2025 13:31:27 +0200 Subject: [PATCH] Asset browser: Copy, cut, paste --- .../src/EditWindows/ContentBrowserWindow.bf | 80 ++- .../src/Multithreading/CopyBackgroundTask.bf | 4 +- .../Multithreading/MoveFilesBackgroundTask.bf | 517 ++++++++++++++++++ 3 files changed, 598 insertions(+), 3 deletions(-) create mode 100644 GlitchyEditor/src/Multithreading/MoveFilesBackgroundTask.bf diff --git a/GlitchyEditor/src/EditWindows/ContentBrowserWindow.bf b/GlitchyEditor/src/EditWindows/ContentBrowserWindow.bf index c8386bb..1850e73 100644 --- a/GlitchyEditor/src/EditWindows/ContentBrowserWindow.bf +++ b/GlitchyEditor/src/EditWindows/ContentBrowserWindow.bf @@ -11,6 +11,7 @@ using GlitchyEngine; using GlitchyEditor.Assets; using System.Diagnostics; using GlitchyEditor.CodeEditors; +using GlitchyEditor.Multithreading; namespace GlitchyEditor.EditWindows { @@ -196,6 +197,9 @@ namespace GlitchyEditor.EditWindows }; private append List _selectedFiles = .() ~ ClearAndDeleteItems!(_); + + private append List _filesToCopy = .() ~ ClearAndDeleteItems!(_); + private bool _cutFiles = false; private append String _assetToRename = .(); private char8[128] _renameFileNameBuffer; @@ -591,6 +595,13 @@ namespace GlitchyEditor.EditWindows } ImGui.AttachTooltip("Opens the C# Solution of this project."); + + ImGui.Separator(); + + if (!_filesToCopy.IsEmpty && ImGui.MenuItem("Paste", "Ctrl+V")) + { + PasteFiles(CurrentDirectory); + } } /// Shows the menu for the given asset creator tree. @@ -767,6 +778,24 @@ namespace GlitchyEditor.EditWindows { NavigateForward(); } + + if (Input.IsKeyPressed(.Control)) + { + if (Input.IsKeyPressing(.C)) + { + CopySelectedFiles(cutFiles: false); + } + + if (Input.IsKeyPressing(.X)) + { + CopySelectedFiles(cutFiles: true); + } + + if (Input.IsKeyPressing(.V)) + { + PasteFiles(CurrentDirectory); + } + } } List> directoryEntries = scope List>(); @@ -943,7 +972,14 @@ namespace GlitchyEditor.EditWindows // TODO: Special Icon for back-folder? SubTexture2D image = _thumbnailManager.GetThumbnail(entry.Value); - ImGui.ImageButton("FileImage", image, (.)IconSize); + ColorRGBA imageColor = ColorRGBA.White; + + if (_filesToCopy.Contains(entry->Path) && _cutFiles) + { + imageColor = ColorRGBA(1, 1, 1, 0.5f); + } + + ImGui.ImageButton("FileImage", image, (.)IconSize, tint_col: (.)(float4)imageColor); if (ImGui.IsRectangleSelecting(ref _rectangleSelection)) { @@ -1274,6 +1310,23 @@ namespace GlitchyEditor.EditWindows ImGui.EndMenu(); } + ImGui.Separator(); + + if (ImGui.MenuItem("Cut", "Ctrl+X")) + { + CopySelectedFiles(cutFiles: true); + } + + if (ImGui.MenuItem("Copy", "Ctrl+C")) + { + CopySelectedFiles(cutFiles: false); + } + + if (!_filesToCopy.IsEmpty && singleEntry && parent != null && ImGui.MenuItem("Paste", "Ctrl+V")) + { + PasteFiles(parent->Path); + } + ImGui.Separator(); if (ImGui.MenuItem("Rename")) @@ -1297,6 +1350,31 @@ namespace GlitchyEditor.EditWindows } } + private void CopySelectedFiles(bool cutFiles) + { + _filesToCopy.ClearAndDeleteItems(); + _selectedFiles.Select((file) => new String(file)).ToList(_filesToCopy); + _cutFiles = cutFiles; + } + + private void PasteFiles(StringView targetDirectoryPath) + { + BackgroundTask backgroundTask; + if (_cutFiles) + { + backgroundTask = new MoveFilesBackgroundTask(_filesToCopy, targetDirectoryPath); + _filesToCopy.ClearAndDeleteItems(); + _cutFiles = false; + } + else + { + backgroundTask = new CopyBackgroundTask(_filesToCopy, targetDirectoryPath); + } + backgroundTask.DeleteWhenEnded = true; + + EditorApp.Instance.BackgroundTaskManager.StartBackgroundTask(backgroundTask); + } + private void OpenPropertiesWindow(TreeNode fileOrFolder) { AssetHandle? assetHandle = fileOrFolder->AssetFile?.AssetConfig?.AssetHandle; diff --git a/GlitchyEditor/src/Multithreading/CopyBackgroundTask.bf b/GlitchyEditor/src/Multithreading/CopyBackgroundTask.bf index 5ff0d68..7c2e5b5 100644 --- a/GlitchyEditor/src/Multithreading/CopyBackgroundTask.bf +++ b/GlitchyEditor/src/Multithreading/CopyBackgroundTask.bf @@ -136,7 +136,7 @@ class CopyBackgroundTask : BackgroundTask if (Directory.Exists(targetPath) || _targetPaths.Contains(targetPath)) { - if (CurrentFileMode.HasFlag(.KeepBoth)) + if (CurrentFileMode.HasFlag(.KeepBoth) || targetPath == sourcePath) { Path.FindFreePath(_targetDirectoryPath, fileName, "", targetPath, fileName, _targetPaths); } @@ -184,7 +184,7 @@ class CopyBackgroundTask : BackgroundTask if (File.Exists(targetPath) || _targetPaths.Contains(targetPath)) { - if (CurrentFileMode.HasFlag(.KeepBoth)) + if (CurrentFileMode.HasFlag(.KeepBoth) || targetPath == sourcePath) { let fileExtension = scope String(); Path.GetExtension(targetPath, fileExtension); diff --git a/GlitchyEditor/src/Multithreading/MoveFilesBackgroundTask.bf b/GlitchyEditor/src/Multithreading/MoveFilesBackgroundTask.bf new file mode 100644 index 0000000..84dcc8c --- /dev/null +++ b/GlitchyEditor/src/Multithreading/MoveFilesBackgroundTask.bf @@ -0,0 +1,517 @@ +using System; +using System.IO; +using System.Collections; +using System.Diagnostics; +using ImGui; +using System.Threading; + +namespace GlitchyEditor.Multithreading; + +class MoveFilesBackgroundTask : BackgroundTask +{ + private List _sourcePath ~ {ClearAndDeleteItems!(_); delete:append _;}; + private String _targetDirectoryPath ~ delete:append _; + + private int _totalEntriesToMove; + private append Queue _pathsToMove = .() ~ ClearAndDeleteItems!(_); + private append Queue _scanQueue = .() ~ ClearAndDeleteItems!(_); + /// Contains the file paths, that will definitely exist after copying. This is used for file renaming during scanning. + private append HashSet _targetPaths = .(); + + private enum ConflictResolutionMode + { + None, + OverwriteFile = 1, + KeepBoth = 2, + CombineDirectories = 4, + SkipFiles = 8, + SkipDirectories = 16, + SkipNotFound = 32, + IgnoreUnexpectedErrors = 64 + } + + private ConflictResolutionMode _nextFileMode; + private ConflictResolutionMode _allFilesMode; + + private ConflictResolutionMode CurrentFileMode => _nextFileMode | _allFilesMode; + + private class CopyInfo + { + public append String SourcePath = .(); + // If this entry is actually inside a directory we copy, this contains the path of this entry inside this directory. + public append String SubPath = .(); + public append String TargetPath = .(); + public ConflictResolutionMode ConflictResolutionMode; + + [AllowAppend] + public this(StringView sourcePath, StringView targetPath, ConflictResolutionMode conflictResolutionMode) + { + SourcePath.Set(sourcePath); + TargetPath.Set(targetPath); + ConflictResolutionMode = conflictResolutionMode; + } + } + + public bool ScanningFiles => !_scanQueue.IsEmpty; + + [AllowAppend] + public this(List sourcePaths, StringView targetDirectoryPath) + { + List sourcePathList = append List(sourcePaths.Count); + String targetDirectoryPathStr = append String(targetDirectoryPath); + + _sourcePath = sourcePathList; + _targetDirectoryPath = targetDirectoryPathStr; + + for (String path in sourcePaths) + { + _sourcePath.Add(new String(path)); + _scanQueue.Add(new CopyInfo(path, "", .None)); + } + + _totalEntriesToMove = sourcePaths.Count; + } + + public enum CopyError : IDisposable + { + case None; + case TargetDirectoryIsNotDirectory; + case TargetFileExists(String FileName); + case TargetDirectoryExists(String DirectoryName); + case EntryNotFound(String SourcePath); + case UnexpectedError(String Message); + + public void Dispose() + { + switch (this) + { + case .None: + case .TargetDirectoryIsNotDirectory: + case .TargetFileExists(let FileName): + delete FileName; + case .TargetDirectoryExists(let DirectoryName): + delete DirectoryName; + case .EntryNotFound(let SourcePath): + delete SourcePath; + case .UnexpectedError(let Message): + delete Message; + } + } + } + + private Result CollectFilesToMove() + { + CopyInfo currentEntry = null; + defer + { + if (currentEntry != null && @return case .Err) + { + _scanQueue.AddFront(currentEntry); + } + } + + void AddToMoveQueue(CopyInfo entry, StringView targetPath, ConflictResolutionMode conflictResolutionMode) + { + entry.TargetPath.Set(targetPath); + entry.ConflictResolutionMode = conflictResolutionMode; + _pathsToMove.Add(entry); + _targetPaths.Add(entry.TargetPath); + } + + while (!_scanQueue.IsEmpty && Running) + { + currentEntry = _scanQueue.PopFront(); + + let sourcePath = (StringView)currentEntry.SourcePath; + + let fileName = scope String(); + Path.GetFileName(sourcePath, fileName); + + let targetPath = scope String(); + Path.Combine(targetPath, _targetDirectoryPath, currentEntry.SubPath, fileName); + + if (Directory.Exists(sourcePath)) + { + bool skip = false; + + if (Directory.Exists(targetPath) || _targetPaths.Contains(targetPath)) + { + if (CurrentFileMode.HasFlag(.KeepBoth)) + { + Path.FindFreePath(_targetDirectoryPath, fileName, "", targetPath, fileName, _targetPaths); + } + else if (CurrentFileMode.HasFlag(.SkipDirectories)) + { + skip = true; + } + else if (Enum.HasAnyFlag(CurrentFileMode, .CombineDirectories)) + { + // Intentionally do nothing. + } + else + { + return .Err(.TargetDirectoryExists(new String(fileName))); + } + } + + if (skip) + { + delete currentEntry; + } + else + { + // Current node is ready, add it to move-queue. + AddToMoveQueue(currentEntry, targetPath, CurrentFileMode); + + if (Enum.HasAnyFlag(CurrentFileMode, .CombineDirectories)) + { + // Add nested files and directories to the scan-queue. + for (FileFindEntry e in Directory.Enumerate(currentEntry.SourcePath)) + { + let entryPath = scope String(); + e.GetFilePath(entryPath); + + let newEntry = new CopyInfo(entryPath, targetPath, .None); + _scanQueue.Add(newEntry); + + Path.Combine(newEntry.SubPath, currentEntry.SubPath, fileName); + } + } + } + + _nextFileMode = .None; + } + else if (File.Exists(sourcePath)) + { + bool skip = false; + + if (File.Exists(targetPath) || _targetPaths.Contains(targetPath)) + { + if (CurrentFileMode.HasFlag(.KeepBoth)) + { + let fileExtension = scope String(); + Path.GetExtension(targetPath, fileExtension); + + StringView fileNameWithoutExtension = fileName.Substring(0..<^fileExtension.Length); + + Path.FindFreePath(_targetDirectoryPath, fileNameWithoutExtension, fileExtension, targetPath, fileName, _targetPaths); + } + else if (CurrentFileMode.HasFlag(.SkipFiles)) + { + skip = true; + } + else if (Enum.HasAnyFlag(CurrentFileMode, .OverwriteFile)) + { + // Intentionally do nothing. + } + else + { + return .Err(.TargetFileExists(new String(fileName))); + } + } + + if (skip) + { + delete currentEntry; + } + else + { + AddToMoveQueue(currentEntry, targetPath, CurrentFileMode); + } + + _nextFileMode = .None; + } + } + + _totalEntriesToMove = _pathsToMove.Count; + + return .Ok; + } + + private CopyError _currentError = .None ~ _.Dispose(); + + public override RunResult Run() + { + _currentError.Dispose(); + _currentError = .None; + if (CollectFilesToMove() case .Err(out _currentError) || Paused) + { + return .Pause; + } + + while (!_pathsToMove.IsEmpty && Running) + { + CopyInfo currentPath = _pathsToMove.Peek(); + + if (MovePath(currentPath) case .Err(out _currentError) || Paused) + { + return .Pause; + } + + _pathsToMove.PopFront(); + delete currentPath; + } + + return .Finished; + } + + private Result MovePath(CopyInfo copyInfo) + { + if (Directory.Exists(copyInfo.SourcePath)) + { + if (Directory.Exists(copyInfo.TargetPath)) + { + if (copyInfo.ConflictResolutionMode.HasFlag(.CombineDirectories)) + { + // We don't have to do anything, because we will be using the target directory. + return .Ok; + } + else if (!CurrentFileMode.HasFlag(.IgnoreUnexpectedErrors)) + { + return .Err(.UnexpectedError(new $"The target directory already exists, but it wasn't properly handled during the scanning phase.")); + } + } + + if (Directory.Move(copyInfo.SourcePath, copyInfo.TargetPath) case .Err(let err) && + !CurrentFileMode.HasFlag(.IgnoreUnexpectedErrors)) + { + return .Err(.UnexpectedError(new $"Failed to move Directory {copyInfo.TargetPath}: {err}")); + } + } + else if (File.Exists(copyInfo.SourcePath)) + { + bool forceOverwrite = false; + if (File.Exists(copyInfo.TargetPath)) + { + if (copyInfo.ConflictResolutionMode.HasFlag(.OverwriteFile)) + { + forceOverwrite = true; + } + else if (!CurrentFileMode.HasFlag(.IgnoreUnexpectedErrors)) + { + return .Err(.UnexpectedError(new $"The target file already exists, but it wasn't properly handled during the scanning phase.")); + } + } + + if (File.Move(copyInfo.SourcePath, copyInfo.TargetPath) case .Err(let err) && + !CurrentFileMode.HasFlag(.IgnoreUnexpectedErrors)) + { + return .Err(.UnexpectedError(new $"Failed to move File {copyInfo.TargetPath}: {err}")); + } + } + else + { + if (copyInfo.ConflictResolutionMode.HasFlag(.SkipNotFound)) + return .Ok; + else + return .Err(.EntryNotFound(new String(copyInfo.SourcePath))); + } + + return .Ok; + } + + public override void OnRenderPopup() + { + String title = scope .("Copying files..."); + + if (ImGui.Begin(title, null, .NoDocking | .NoCollapse | .Modal | .NoResize | .AlwaysAutoResize)) + { + if (ScanningFiles) + { + ImGui.ProgressBar(-1.0f * (float)ImGui.GetTime(), .(-1, 0), scope $"Found {_pathsToMove.Count} files..."); + } + else + { + int copiedFiles = _totalEntriesToMove - _pathsToMove.Count; + + ImGui.ProgressBar(copiedFiles / _totalEntriesToMove, .(-1, 0), scope $"Copied {copiedFiles} / {_totalEntriesToMove} files."); + } + + + switch (_currentError) + { + case .TargetFileExists(let fileName): + ImGui.PushStyleColor(.Text, 0xFF0000FF); + ImGui.TextUnformatted(scope $"A file with the name \"{fileName}\" already exists."); + ImGui.PopStyleColor(); + case .TargetDirectoryExists(let directoryName): + ImGui.PushStyleColor(.Text, 0xFF0000FF); + ImGui.TextUnformatted(scope $"A directory with the name \"{directoryName}\" already exists."); + ImGui.PopStyleColor(); + case .EntryNotFound(let SourcePath): + ImGui.PushStyleColor(.Text, 0xFF0000FF); + ImGui.TextUnformatted(scope $"\"{SourcePath}\" doesn't exist."); + ImGui.PopStyleColor(); + case .UnexpectedError(let Message): + ImGui.PushStyleColor(.Text, 0xFF0000FF); + ImGui.TextUnformatted(scope $"Unexpected error: {Message}"); + ImGui.PopStyleColor(); + default: + // Do nothing + } + + if (ImGui.BeginTable("buttonTable", 2)) + { + switch (_currentError) + { + case .None: + // Do nothing + case .TargetDirectoryIsNotDirectory: + // This shouldn't be possible + Debug.Break(); + case .TargetFileExists(let fileName): + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + + if (ImGui.Button("Overwrite the target file.")) + { + _nextFileMode = .OverwriteFile; + Continue(); + } + ImGui.TableSetColumnIndex(1); + if (ImGui.Button("Overwrite all conflicting target files.")) + { + _allFilesMode |= .OverwriteFile; + Continue(); + } + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + + if (ImGui.Button("Rename copied file.")) + { + _nextFileMode = .KeepBoth; + Continue(); + } + ImGui.TableSetColumnIndex(1); + if (ImGui.Button("Rename all conflicting copied files.")) + { + _allFilesMode |= .KeepBoth; + Continue(); + } + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + + if (ImGui.Button("Skip this file.")) + { + _nextFileMode = .SkipFiles; + Continue(); + } + ImGui.TableSetColumnIndex(1); + if (ImGui.Button("Skip all conflicting files.")) + { + _allFilesMode |= .SkipFiles; + Continue(); + } + + case .TargetDirectoryExists(let directoryName): + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + + if (ImGui.Button("Combine with target.")) + { + _nextFileMode |= .CombineDirectories; + Continue(); + } + ImGui.TableSetColumnIndex(1); + if (ImGui.Button("Combine all conflicting directories.")) + { + _allFilesMode |= .CombineDirectories; + Continue(); + } + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + + if (ImGui.Button("Rename copied directory.")) + { + _nextFileMode |= .KeepBoth; + Continue(); + } + ImGui.TableSetColumnIndex(1); + if (ImGui.Button("Rename all conflicting directories.")) + { + _allFilesMode |= .KeepBoth; + Continue(); + } + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + + if (ImGui.Button("Skip this directory.")) + { + _nextFileMode |= .SkipDirectories; + Continue(); + } + ImGui.TableSetColumnIndex(1); + if (ImGui.Button("Skip all conflicting directories.")) + { + _allFilesMode |= .SkipFiles; + Continue(); + } + case .EntryNotFound(let SourcePath): + if (ImGui.Button("Skip")) + { + _nextFileMode |= .SkipNotFound; + Continue(); + } + ImGui.SameLine(); + if (ImGui.Button("Skip all")) + { + _allFilesMode |= .SkipNotFound; + Continue(); + } + case .UnexpectedError(let Message): + if (ImGui.Button("Retry")) + { + Continue(); + } + ImGui.SameLine(); + if (ImGui.Button("Ignore")) + { + _nextFileMode |= .IgnoreUnexpectedErrors; + Continue(); + } + ImGui.SameLine(); + if (ImGui.Button("Ignore all unexpected")) + { + _allFilesMode |= .IgnoreUnexpectedErrors; + Continue(); + } + } + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + + if (_currentError case .None) + { + if (Paused) + { + if (ImGui.Button("Continue")) + { + Continue(); + } + } + else + { + if (ImGui.Button("Pause")) + { + Pause(); + } + } + + ImGui.SameLine(); + } + + if (ImGui.Button("Cancel")) + { + Abort(); + } + ImGui.EndTable(); + } + + ImGui.End(); + } + } +}