using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
///
/// Unity Editor 工具:识别并清理项目中没有对应资源文件的孤立 .meta 文件。
///
/// 功能:
/// - 扫描 Assets 目录下所有 .meta 文件
/// - 检查每个 .meta 文件对应的资源是否存在
/// - 列出所有孤立的 .meta 文件
/// - 支持一键删除所有孤立 .meta 文件
/// - 也可通过 Assets 右键菜单对选中文件夹进行扫描
///
/// 菜单位置: Tools/Ichni/Clean Unused Meta Files
/// 右键菜单: Assets/Clean Unused Meta Files (in folder)
///
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";
///
/// 扫描整个 Assets 目录
///
[MenuItem(MENU_ROOT)]
private static void CleanUnusedMetaFiles()
{
string assetsPath = Application.dataPath;
ScanAndCleanMetaFiles(assetsPath, "Entire Assets");
}
///
/// 在项目窗口(Project窗口)中选中文件夹时,右键菜单项
///
[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);
}
///
/// 验证右键菜单是否可用:仅当选中的是文件夹时启用
///
[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);
}
///
/// 核心扫描与清理逻辑
///
private static void ScanAndCleanMetaFiles(string scanDirectory, string displayName)
{
// 收集所有 .meta 文件
string[] allMetaFiles = Directory.GetFiles(scanDirectory, "*.meta", SearchOption.AllDirectories);
List orphanedMetaFiles = new List();
// 进度条
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 orphanedFiles;
private Vector2 scrollPos;
private string displayName;
private bool[] selected;
public static void ShowWindow(List orphanedFiles, string displayName)
{
OrphanedMetaWindow window = GetWindow(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 toDelete = new List();
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 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
///
/// 可读的文件大小格式化
///
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
}