Files
ichni_Creator_Studio/Assets/Editor/MetaFileCleaner.cs
2026-07-16 10:55:30 +08:00

335 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
/// <summary>
/// Unity Editor 工具:识别并清理项目中没有对应资源文件的孤立 .meta 文件。
///
/// 功能:
/// - 扫描 Assets 目录下所有 .meta 文件
/// - 检查每个 .meta 文件对应的资源是否存在
/// - 列出所有孤立的 .meta 文件
/// - 支持一键删除所有孤立 .meta 文件
/// - 也可通过 Assets 右键菜单对选中文件夹进行扫描
///
/// 菜单位置: Tools/Ichni/Clean Unused Meta Files
/// 右键菜单: Assets/Clean Unused Meta Files (in folder)
/// </summary>
public static class MetaFileCleaner
{
private const string MENU_ROOT = "Tools/Ichni/Clean Unused Meta Files";
private const string ASSETS_CONTEXT_MENU = "Assets/Clean Unused Meta Files";
/// <summary>
/// 扫描整个 Assets 目录
/// </summary>
[MenuItem(MENU_ROOT)]
private static void CleanUnusedMetaFiles()
{
string assetsPath = Application.dataPath;
ScanAndCleanMetaFiles(assetsPath, "Entire Assets");
}
/// <summary>
/// 在项目窗口Project窗口中选中文件夹时右键菜单项
/// </summary>
[MenuItem(ASSETS_CONTEXT_MENU)]
private static void CleanUnusedMetaFilesFromSelection()
{
// 获取当前选中的资源路径(相对路径)
string selectedPath = AssetDatabase.GetAssetPath(Selection.activeObject);
if (string.IsNullOrEmpty(selectedPath))
{
EditorUtility.DisplayDialog("No Selection", "Please select a folder in the Project window.", "OK");
return;
}
string fullPath = Path.GetFullPath(selectedPath);
if (!Directory.Exists(fullPath))
{
EditorUtility.DisplayDialog("Not a Folder", "Please select a folder, not a file.", "OK");
return;
}
ScanAndCleanMetaFiles(fullPath, selectedPath);
}
/// <summary>
/// 验证右键菜单是否可用:仅当选中的是文件夹时启用
/// </summary>
[MenuItem(ASSETS_CONTEXT_MENU, true)]
private static bool ValidateCleanUnusedMetaFilesFromSelection()
{
string selectedPath = AssetDatabase.GetAssetPath(Selection.activeObject);
if (string.IsNullOrEmpty(selectedPath))
return false;
string fullPath = Path.GetFullPath(selectedPath);
return Directory.Exists(fullPath);
}
/// <summary>
/// 核心扫描与清理逻辑
/// </summary>
private static void ScanAndCleanMetaFiles(string scanDirectory, string displayName)
{
// 收集所有 .meta 文件
string[] allMetaFiles = Directory.GetFiles(scanDirectory, "*.meta", SearchOption.AllDirectories);
List<string> orphanedMetaFiles = new List<string>();
// 进度条
for (int i = 0; i < allMetaFiles.Length; i++)
{
string metaFile = allMetaFiles[i];
if (EditorUtility.DisplayCancelableProgressBar(
"Scanning .meta files",
$"Checking: {metaFile}",
(float)i / allMetaFiles.Length))
{
EditorUtility.ClearProgressBar();
Debug.LogWarning("[MetaFileCleaner] Scan cancelled by user.");
return;
}
// .meta 文件对应的原始资源路径
// 例如: "Assets/Textures/foo.png.meta" → "Assets/Textures/foo.png"
string assetPath = metaFile.Substring(0, metaFile.Length - ".meta".Length);
// 检查原始资源是否存在(文件或目录)
if (!File.Exists(assetPath) && !Directory.Exists(assetPath))
{
orphanedMetaFiles.Add(metaFile);
}
}
EditorUtility.ClearProgressBar();
// 显示结果
if (orphanedMetaFiles.Count == 0)
{
EditorUtility.DisplayDialog(
"Clean Unused Meta Files",
$"Scan completed: {displayName}\n\nNo orphaned .meta files found. ({allMetaFiles.Length} total .meta files checked)",
"OK");
return;
}
// 有孤立文件,显示详细窗口
OrphanedMetaWindow.ShowWindow(orphanedMetaFiles, displayName);
}
#region Orphaned Meta Window
private class OrphanedMetaWindow : EditorWindow
{
private List<string> orphanedFiles;
private Vector2 scrollPos;
private string displayName;
private bool[] selected;
public static void ShowWindow(List<string> orphanedFiles, string displayName)
{
OrphanedMetaWindow window = GetWindow<OrphanedMetaWindow>(true, "Orphaned .meta Files", true);
window.orphanedFiles = orphanedFiles;
window.displayName = displayName;
window.selected = new bool[orphanedFiles.Count];
for (int i = 0; i < window.selected.Length; i++)
window.selected[i] = true; // 默认全选
window.minSize = new Vector2(600, 300);
window.Show();
}
private void OnGUI()
{
GUILayout.BeginVertical(EditorStyles.helpBox);
GUILayout.Label($"Orphaned .meta files found in: {displayName}", EditorStyles.boldLabel);
GUILayout.Label($"Total: {orphanedFiles.Count} orphaned .meta files (out of all files scanned)",
EditorStyles.miniLabel);
GUILayout.Space(4);
// 统计选中数量
int selectedCount = selected.Count(s => s);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Select All", GUILayout.Width(100)))
{
for (int i = 0; i < selected.Length; i++)
selected[i] = true;
}
if (GUILayout.Button("Deselect All", GUILayout.Width(100)))
{
for (int i = 0; i < selected.Length; i++)
selected[i] = false;
}
GUILayout.FlexibleSpace();
GUI.color = new Color(0.8f, 0.2f, 0.2f);
if (GUILayout.Button($"Delete Selected ({selectedCount})", GUILayout.Width(180)))
{
ConfirmAndDelete();
}
GUI.color = Color.white;
if (GUILayout.Button("Cancel", GUILayout.Width(80)))
{
Close();
}
}
GUILayout.EndVertical();
GUILayout.Space(8);
// 文件列表
scrollPos = EditorGUILayout.BeginScrollView(scrollPos);
for (int i = 0; i < orphanedFiles.Count; i++)
{
string relativePath = GetRelativePath(orphanedFiles[i]);
selected[i] = EditorGUILayout.ToggleLeft(relativePath, selected[i]);
}
EditorGUILayout.EndScrollView();
GUILayout.Space(4);
GUILayout.Label("Tip: Hover over an item to see full path in tooltip", EditorStyles.miniLabel);
}
private void ConfirmAndDelete()
{
List<string> toDelete = new List<string>();
for (int i = 0; i < orphanedFiles.Count; i++)
{
if (selected[i])
toDelete.Add(orphanedFiles[i]);
}
if (toDelete.Count == 0)
{
EditorUtility.DisplayDialog("Nothing Selected", "Please select at least one file to delete.", "OK");
return;
}
// 计算可读的文件大小总计
long totalBytes = 0;
foreach (string file in toDelete)
{
try
{
totalBytes += new FileInfo(file).Length;
}
catch { /* skip */ }
}
string sizeStr = FormatBytes(totalBytes);
bool confirm = EditorUtility.DisplayDialog(
"Confirm Delete",
$"Are you sure you want to delete {toDelete.Count} orphaned .meta file(s)?\n\n" +
$"Total size: {sizeStr}\n\n" +
"This operation cannot be undone. It is recommended to have a backup.\n\n" +
"Note: If these .meta files are related to assets you plan to restore, " +
"deleting them may cause GUID conflicts. Only proceed if you are sure the " +
"original assets are no longer needed.",
"Delete",
"Cancel");
if (!confirm)
return;
DeleteFiles(toDelete);
}
private void DeleteFiles(List<string> files)
{
int deleted = 0;
int failed = 0;
long recoveredBytes = 0;
for (int i = 0; i < files.Count; i++)
{
string file = files[i];
if (EditorUtility.DisplayCancelableProgressBar(
"Deleting orphaned .meta files",
$"Deleting: {GetRelativePath(file)} ({i + 1}/{files.Count})",
(float)i / files.Count))
{
EditorUtility.ClearProgressBar();
Debug.LogWarning($"[MetaFileCleaner] Deletion cancelled after {deleted} files deleted.");
break;
}
try
{
FileInfo fi = new FileInfo(file);
long size = fi.Length;
File.Delete(file);
// 同时删除 .meta 对应的资源文件(如果存在空的残留文件)
string assetPath = file.Substring(0, file.Length - ".meta".Length);
if (File.Exists(assetPath) && new FileInfo(assetPath).Length == 0)
{
File.Delete(assetPath);
Debug.Log($"[MetaFileCleaner] Also deleted empty residual file: {assetPath}");
}
deleted++;
recoveredBytes += size;
}
catch (System.Exception ex)
{
Debug.LogError($"[MetaFileCleaner] Failed to delete: {file}\n{ex.Message}");
failed++;
}
}
EditorUtility.ClearProgressBar();
AssetDatabase.Refresh();
string summary = $"Deleted: {deleted} file(s).\n" +
$"Failed: {failed} file(s).\n" +
$"Space recovered: {FormatBytes(recoveredBytes)}";
Debug.Log($"[MetaFileCleaner] Cleanup complete.\n{summary}");
EditorUtility.DisplayDialog("Cleanup Complete", summary, "OK");
Close();
}
private string GetRelativePath(string fullPath)
{
string assetsIndex = "Assets";
int idx = fullPath.IndexOf(assetsIndex);
if (idx >= 0)
return fullPath.Substring(idx);
return fullPath;
}
}
#endregion
#region Utilities
/// <summary>
/// 可读的文件大小格式化
/// </summary>
private static string FormatBytes(long bytes)
{
if (bytes < 1024)
return $"{bytes} B";
if (bytes < 1024 * 1024)
return $"{bytes / 1024.0:F1} KB";
return $"{bytes / (1024.0 * 1024.0):F2} MB";
}
#endregion
}