2026-04-15 22:47:42 +08:00
|
|
|
#include "ProjectBrowserModelInternal.h"
|
2026-04-15 08:24:06 +08:00
|
|
|
|
2026-04-15 23:13:09 +08:00
|
|
|
#include "Internal/StringEncoding.h"
|
2026-04-15 08:24:06 +08:00
|
|
|
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
#include <cctype>
|
2026-04-19 00:03:25 +08:00
|
|
|
#include <cwctype>
|
|
|
|
|
#include <initializer_list>
|
2026-04-15 08:24:06 +08:00
|
|
|
#include <system_error>
|
|
|
|
|
|
2026-04-15 22:47:42 +08:00
|
|
|
namespace XCEngine::UI::Editor::App::ProjectBrowserModelInternal {
|
2026-04-15 08:24:06 +08:00
|
|
|
|
2026-04-15 22:47:42 +08:00
|
|
|
std::string ToLowerCopy(std::string value) {
|
2026-04-15 08:24:06 +08:00
|
|
|
std::transform(
|
|
|
|
|
value.begin(),
|
|
|
|
|
value.end(),
|
|
|
|
|
value.begin(),
|
|
|
|
|
[](unsigned char character) {
|
|
|
|
|
return static_cast<char>(std::tolower(character));
|
|
|
|
|
});
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 22:47:42 +08:00
|
|
|
std::string PathToUtf8String(const std::filesystem::path& path) {
|
2026-04-15 23:13:09 +08:00
|
|
|
return App::Internal::WideToUtf8(path.native());
|
2026-04-15 08:24:06 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 22:47:42 +08:00
|
|
|
std::string NormalizePathSeparators(std::string value) {
|
2026-04-15 08:24:06 +08:00
|
|
|
std::replace(value.begin(), value.end(), '\\', '/');
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 22:47:42 +08:00
|
|
|
std::string BuildRelativeItemId(
|
2026-04-15 08:24:06 +08:00
|
|
|
const std::filesystem::path& path,
|
|
|
|
|
const std::filesystem::path& assetsRoot) {
|
|
|
|
|
const std::filesystem::path relative =
|
|
|
|
|
std::filesystem::relative(path, assetsRoot.parent_path());
|
|
|
|
|
const std::string normalized =
|
|
|
|
|
NormalizePathSeparators(PathToUtf8String(relative.lexically_normal()));
|
|
|
|
|
return normalized.empty() ? std::string(kAssetsRootId) : normalized;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 00:03:25 +08:00
|
|
|
std::string BuildRelativeProjectPath(
|
|
|
|
|
const std::filesystem::path& path,
|
|
|
|
|
const std::filesystem::path& projectRoot) {
|
|
|
|
|
if (projectRoot.empty() || path.empty()) {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::filesystem::path relative =
|
|
|
|
|
std::filesystem::relative(path, projectRoot);
|
|
|
|
|
const std::string normalized =
|
|
|
|
|
NormalizePathSeparators(PathToUtf8String(relative.lexically_normal()));
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 22:47:42 +08:00
|
|
|
std::string BuildAssetDisplayName(const std::filesystem::path& path, bool directory) {
|
2026-04-15 08:24:06 +08:00
|
|
|
if (directory) {
|
|
|
|
|
return PathToUtf8String(path.filename());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::string filename = PathToUtf8String(path.filename());
|
|
|
|
|
const std::size_t extensionOffset = filename.find_last_of('.');
|
|
|
|
|
if (extensionOffset == std::string::npos || extensionOffset == 0u) {
|
|
|
|
|
return filename;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return filename.substr(0u, extensionOffset);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 00:03:25 +08:00
|
|
|
std::string BuildAssetNameWithExtension(const std::filesystem::path& path, bool directory) {
|
|
|
|
|
return directory
|
|
|
|
|
? PathToUtf8String(path.filename())
|
|
|
|
|
: PathToUtf8String(path.filename());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 22:47:42 +08:00
|
|
|
bool IsMetaFile(const std::filesystem::path& path) {
|
2026-04-15 08:24:06 +08:00
|
|
|
return ToLowerCopy(path.extension().string()) == ".meta";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 22:47:42 +08:00
|
|
|
bool HasChildDirectories(const std::filesystem::path& folderPath) {
|
2026-04-15 08:24:06 +08:00
|
|
|
std::error_code errorCode = {};
|
|
|
|
|
const std::filesystem::directory_iterator end = {};
|
|
|
|
|
for (std::filesystem::directory_iterator iterator(folderPath, errorCode);
|
|
|
|
|
!errorCode && iterator != end;
|
|
|
|
|
iterator.increment(errorCode)) {
|
|
|
|
|
if (iterator->is_directory(errorCode)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 22:47:42 +08:00
|
|
|
std::vector<std::filesystem::path> CollectSortedChildDirectories(
|
2026-04-15 08:24:06 +08:00
|
|
|
const std::filesystem::path& folderPath) {
|
|
|
|
|
std::vector<std::filesystem::path> paths = {};
|
|
|
|
|
std::error_code errorCode = {};
|
|
|
|
|
const std::filesystem::directory_iterator end = {};
|
|
|
|
|
for (std::filesystem::directory_iterator iterator(folderPath, errorCode);
|
|
|
|
|
!errorCode && iterator != end;
|
|
|
|
|
iterator.increment(errorCode)) {
|
|
|
|
|
if (iterator->is_directory(errorCode)) {
|
|
|
|
|
paths.push_back(iterator->path());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::sort(
|
|
|
|
|
paths.begin(),
|
|
|
|
|
paths.end(),
|
|
|
|
|
[](const std::filesystem::path& lhs, const std::filesystem::path& rhs) {
|
|
|
|
|
return ToLowerCopy(PathToUtf8String(lhs.filename())) <
|
|
|
|
|
ToLowerCopy(PathToUtf8String(rhs.filename()));
|
|
|
|
|
});
|
|
|
|
|
return paths;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 00:03:25 +08:00
|
|
|
std::wstring MakePathKey(const std::filesystem::path& path) {
|
|
|
|
|
std::wstring key = path.lexically_normal().generic_wstring();
|
|
|
|
|
std::transform(key.begin(), key.end(), key.begin(), ::towlower);
|
|
|
|
|
return key;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool IsSameOrDescendantPath(
|
|
|
|
|
const std::filesystem::path& path,
|
|
|
|
|
const std::filesystem::path& ancestor) {
|
|
|
|
|
const std::wstring pathKey = MakePathKey(path);
|
|
|
|
|
std::wstring ancestorKey = MakePathKey(ancestor);
|
|
|
|
|
if (pathKey.empty() || ancestorKey.empty()) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pathKey == ancestorKey) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ancestorKey.back() != L'/') {
|
|
|
|
|
ancestorKey += L'/';
|
|
|
|
|
}
|
|
|
|
|
return pathKey.rfind(ancestorKey, 0) == 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string TrimAssetName(std::string_view name) {
|
|
|
|
|
const auto first =
|
|
|
|
|
std::find_if_not(name.begin(), name.end(), [](unsigned char character) {
|
|
|
|
|
return std::isspace(character) != 0;
|
|
|
|
|
});
|
|
|
|
|
if (first == name.end()) {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto last =
|
|
|
|
|
std::find_if_not(name.rbegin(), name.rend(), [](unsigned char character) {
|
|
|
|
|
return std::isspace(character) != 0;
|
|
|
|
|
}).base();
|
|
|
|
|
return std::string(first, last);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool HasInvalidAssetName(std::string_view name) {
|
|
|
|
|
if (name.empty() || name == "." || name == "..") {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return name.find_first_of("\\/:*?\"<>|") != std::string_view::npos;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::filesystem::path MakeUniqueFolderPath(
|
|
|
|
|
const std::filesystem::path& parentPath,
|
|
|
|
|
std::string_view preferredName) {
|
|
|
|
|
std::filesystem::path candidatePath =
|
|
|
|
|
parentPath / App::Internal::Utf8ToWide(std::string(preferredName));
|
|
|
|
|
if (!std::filesystem::exists(candidatePath)) {
|
|
|
|
|
return candidatePath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (std::size_t suffix = 1u;; ++suffix) {
|
|
|
|
|
candidatePath =
|
|
|
|
|
parentPath / App::Internal::Utf8ToWide(
|
|
|
|
|
std::string(preferredName) + " " + std::to_string(suffix));
|
|
|
|
|
if (!std::filesystem::exists(candidatePath)) {
|
|
|
|
|
return candidatePath;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::filesystem::path MakeUniqueFilePath(
|
|
|
|
|
const std::filesystem::path& parentPath,
|
|
|
|
|
const std::filesystem::path& preferredFileName) {
|
|
|
|
|
std::filesystem::path candidatePath = parentPath / preferredFileName;
|
|
|
|
|
if (!std::filesystem::exists(candidatePath)) {
|
|
|
|
|
return candidatePath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::wstring stem = preferredFileName.stem().wstring();
|
|
|
|
|
const std::wstring extension = preferredFileName.extension().wstring();
|
|
|
|
|
for (std::size_t suffix = 1u;; ++suffix) {
|
|
|
|
|
candidatePath =
|
|
|
|
|
parentPath /
|
|
|
|
|
std::filesystem::path(
|
|
|
|
|
stem + L" " + std::to_wstring(suffix) + extension);
|
|
|
|
|
if (!std::filesystem::exists(candidatePath)) {
|
|
|
|
|
return candidatePath;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string BuildRenamedEntryName(
|
|
|
|
|
const std::filesystem::path& sourcePath,
|
|
|
|
|
std::string_view requestedName) {
|
|
|
|
|
if (std::filesystem::is_directory(sourcePath)) {
|
|
|
|
|
return std::string(requestedName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::filesystem::path requestedPath =
|
|
|
|
|
App::Internal::Utf8ToWide(std::string(requestedName));
|
|
|
|
|
if (requestedPath.has_extension()) {
|
|
|
|
|
return PathToUtf8String(requestedPath.filename());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return std::string(requestedName) + PathToUtf8String(sourcePath.extension());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::filesystem::path GetMetaSidecarPath(const std::filesystem::path& assetPath) {
|
|
|
|
|
return std::filesystem::path(assetPath.native() + std::filesystem::path(L".meta").native());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RemoveMetaSidecarIfPresent(const std::filesystem::path& assetPath) {
|
|
|
|
|
std::error_code errorCode = {};
|
|
|
|
|
std::filesystem::remove_all(GetMetaSidecarPath(assetPath), errorCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
std::filesystem::path MakeCaseOnlyRenameTempPath(const std::filesystem::path& sourcePath) {
|
|
|
|
|
const std::filesystem::path parentPath = sourcePath.parent_path();
|
|
|
|
|
const std::wstring sourceStem = sourcePath.filename().wstring();
|
|
|
|
|
|
|
|
|
|
for (std::size_t suffix = 0u;; ++suffix) {
|
|
|
|
|
std::wstring tempName = sourceStem + L".xc_tmp_rename";
|
|
|
|
|
if (suffix > 0u) {
|
|
|
|
|
tempName += std::to_wstring(suffix);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::filesystem::path tempPath = parentPath / tempName;
|
|
|
|
|
if (!std::filesystem::exists(tempPath)) {
|
|
|
|
|
return tempPath;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool MatchesExtension(
|
|
|
|
|
std::wstring_view extension,
|
|
|
|
|
std::initializer_list<std::wstring_view> candidates) {
|
|
|
|
|
for (const std::wstring_view candidate : candidates) {
|
|
|
|
|
if (extension == candidate) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
bool RenamePathCaseAware(
|
|
|
|
|
const std::filesystem::path& sourcePath,
|
|
|
|
|
const std::filesystem::path& destPath) {
|
|
|
|
|
if (MakePathKey(sourcePath) != MakePathKey(destPath)) {
|
|
|
|
|
if (std::filesystem::exists(destPath)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::filesystem::rename(sourcePath, destPath);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sourcePath.filename() == destPath.filename()) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::filesystem::path tempPath = MakeCaseOnlyRenameTempPath(sourcePath);
|
|
|
|
|
std::filesystem::rename(sourcePath, tempPath);
|
|
|
|
|
std::filesystem::rename(tempPath, destPath);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void MoveMetaSidecarIfPresent(
|
|
|
|
|
const std::filesystem::path& sourcePath,
|
|
|
|
|
const std::filesystem::path& destPath) {
|
|
|
|
|
const std::filesystem::path sourceMetaPath = GetMetaSidecarPath(sourcePath);
|
|
|
|
|
if (!std::filesystem::exists(sourceMetaPath)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::filesystem::path destMetaPath = GetMetaSidecarPath(destPath);
|
|
|
|
|
RenamePathCaseAware(sourceMetaPath, destMetaPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool MovePathWithOptionalMeta(
|
|
|
|
|
const std::filesystem::path& sourcePath,
|
|
|
|
|
const std::filesystem::path& destinationPath) {
|
|
|
|
|
std::error_code errorCode = {};
|
|
|
|
|
const std::filesystem::path sourceMetaPath = GetMetaSidecarPath(sourcePath);
|
|
|
|
|
const std::filesystem::path destinationMetaPath = GetMetaSidecarPath(destinationPath);
|
|
|
|
|
const bool moveMeta = std::filesystem::exists(sourceMetaPath, errorCode);
|
|
|
|
|
if (errorCode) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::filesystem::rename(sourcePath, destinationPath, errorCode);
|
|
|
|
|
if (errorCode) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!moveMeta) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::filesystem::rename(sourceMetaPath, destinationMetaPath, errorCode);
|
|
|
|
|
if (!errorCode) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::error_code rollbackError = {};
|
|
|
|
|
std::filesystem::rename(destinationPath, sourcePath, rollbackError);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ProjectBrowserModel::ItemKind ResolveItemKind(
|
|
|
|
|
const std::filesystem::path& path,
|
|
|
|
|
bool directory) {
|
|
|
|
|
if (directory) {
|
|
|
|
|
return ProjectBrowserModel::ItemKind::Folder;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::wstring extension = path.extension().wstring();
|
|
|
|
|
std::transform(extension.begin(), extension.end(), extension.begin(), ::towlower);
|
|
|
|
|
|
|
|
|
|
if (MatchesExtension(extension, { L".xc", L".unity", L".scene" })) {
|
|
|
|
|
return ProjectBrowserModel::ItemKind::Scene;
|
|
|
|
|
}
|
|
|
|
|
if (MatchesExtension(extension, { L".fbx", L".obj", L".gltf", L".glb" })) {
|
|
|
|
|
return ProjectBrowserModel::ItemKind::Model;
|
|
|
|
|
}
|
|
|
|
|
if (extension == L".mat") {
|
|
|
|
|
return ProjectBrowserModel::ItemKind::Material;
|
|
|
|
|
}
|
|
|
|
|
if (MatchesExtension(extension, {
|
|
|
|
|
L".png", L".jpg", L".jpeg", L".tga", L".bmp", L".gif",
|
|
|
|
|
L".psd", L".hdr", L".pic", L".ppm", L".pgm", L".pbm",
|
|
|
|
|
L".pnm", L".dds", L".ktx", L".ktx2", L".webp" })) {
|
|
|
|
|
return ProjectBrowserModel::ItemKind::Texture;
|
|
|
|
|
}
|
|
|
|
|
if (MatchesExtension(extension, { L".cs", L".cpp", L".c", L".h", L".hpp" })) {
|
|
|
|
|
return ProjectBrowserModel::ItemKind::Script;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ProjectBrowserModel::ItemKind::File;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool CanOpenItemKind(ProjectBrowserModel::ItemKind kind) {
|
|
|
|
|
return kind == ProjectBrowserModel::ItemKind::Folder ||
|
|
|
|
|
kind == ProjectBrowserModel::ItemKind::Scene;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool CanPreviewItemKind(ProjectBrowserModel::ItemKind kind) {
|
|
|
|
|
return kind == ProjectBrowserModel::ItemKind::Texture;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 22:47:42 +08:00
|
|
|
} // namespace XCEngine::UI::Editor::App::ProjectBrowserModelInternal
|