Asset browser: Copy, cut, paste

This commit is contained in:
Simon Lübeß
2025-07-10 13:31:27 +02:00
parent 39a825634a
commit 1b63c922da
3 changed files with 598 additions and 3 deletions
@@ -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<String> _selectedFiles = .() ~ ClearAndDeleteItems!(_);
private append List<String> _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<TreeNode<AssetNode>> directoryEntries = scope List<TreeNode<AssetNode>>();
@@ -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<AssetNode> fileOrFolder)
{
AssetHandle? assetHandle = fileOrFolder->AssetFile?.AssetConfig?.AssetHandle;
@@ -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);
@@ -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<String> _sourcePath ~ {ClearAndDeleteItems!(_); delete:append _;};
private String _targetDirectoryPath ~ delete:append _;
private int _totalEntriesToMove;
private append Queue<CopyInfo> _pathsToMove = .() ~ ClearAndDeleteItems!(_);
private append Queue<CopyInfo> _scanQueue = .() ~ ClearAndDeleteItems!(_);
/// Contains the file paths, that will definitely exist after copying. This is used for file renaming during scanning.
private append HashSet<StringView> _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<String> sourcePaths, StringView targetDirectoryPath)
{
List<String> sourcePathList = append List<String>(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<void, CopyError> 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<void, CopyError> 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();
}
}
}