335 lines
11 KiB
C#
335 lines
11 KiB
C#
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
|
||
}
|